commit 120420a458d39e3089a4a9aa6616e02d7d2dfa4f Author: urieljareth Date: Thu Aug 13 23:32:13 2026 -0600 Initial commit: Mambo DVD screensaver (static site) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af92a62 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +.opencode diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1393ab0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.opencode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40beeae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:alpine + +COPY index.html styles.css app.js Mambo_png.png mambo_audio.ogg /usr/share/nginx/html/ + +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 + +EXPOSE 80 diff --git a/Mambo_png.png b/Mambo_png.png new file mode 100644 index 0000000..23ed464 Binary files /dev/null and b/Mambo_png.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2fe2f3f --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Mambo DVD + +Salvapantallas tipo logo de DVD: Mambo rebota en bucle por la pantalla, cambia +de color y suena en cada rebote. Cuando cae justo en una esquina, esa esquina +se queda encendida con el color exacto del golpe. + +## Cómo abrirlo + +Doble clic en `index.html`. No necesita servidor ni instalar nada. + +El navegador no deja reproducir audio sin una interacción previa, por eso la +portada pide un clic en **Iniciar** antes de arrancar. + +## Controles + +| Tecla | Acción | +|---|---| +| `Espacio` | Pausar y reanudar (también funciona haciendo clic) | +| `S` | Probar el sonido | +| `E` | Precisión de esquina: Normal · Generosa · Real | +| `C` | Modo de color: Tinte · Sólido · Original | +| `F` | Pantalla completa | +| `+` / `−` | Velocidad, de 0.25× a 4× | +| `R` | Reiniciar contadores y trayectoria | +| `H` | Ocultar el HUD | + +## Modos de color + +- **Tinte** — gira el matiz del peluche conservando la ilustración. +- **Sólido** — silueta plana de un color, que es lo que hacía el logo original. +- **Original** — sin recoloreo. + +## Precisión de esquina + +Una esquina exacta significa rebotar en los dos ejes en el mismo fotograma, y +eso casi nunca pasa: en pruebas a 1920×1080 con margen fijo de 15 px salieron +0 esquinas en 60 segundos. Por eso la tolerancia es un porcentaje del lado +menor de la ventana y no un valor en píxeles. + +- **Real** (0.8 %) — purista, pueden pasar muchos minutos. +- **Normal** (4.5 %) — por defecto, una esquina cada pocos minutos. +- **Generosa** (11 %) — para verlo seguido. + +## Archivos + +| Archivo | Qué hace | +|---|---| +| `index.html` | Estructura: escenario, sprite, HUD y portada | +| `styles.css` | Paleta OSD de reproductor de DVD, animaciones de impacto | +| `app.js` | Física del rebote, detección de esquina, color y audio | +| `Mambo_png.png` | El sprite | +| `mambo_audio.ogg` | El sonido del rebote | + +## Detalles de implementación + +- El movimiento va por **delta de tiempo**, no por fotograma, así que se ve + igual a 60 Hz que a 144 Hz. El delta se acota a 50 ms para que volver de una + pestaña en segundo plano no provoque un salto. +- El modo Sólido se pinta en **canvas con `source-in`**, no con `mask-image`. + Las máscaras CSS pasan por CORS y en `file://` (origen `null`) el sprite + quedaba invisible. El canvas solo compone y nunca lee píxeles, así que el + *taint* no molesta. +- El desbloqueo de audio reproduce en silencio y **deja terminar** el clip. La + versión con `pause()` abortaba el golpe real que cayera dentro de esa + ventana y lo perdía con un `AbortError`. +- El color avanza con el **ángulo áureo** (137.508°) para que dos rebotes + seguidos nunca den colores parecidos. diff --git a/app.js b/app.js new file mode 100644 index 0000000..5b78e88 --- /dev/null +++ b/app.js @@ -0,0 +1,442 @@ +/* ============================================================ + Mambo DVD — salvapantallas + Rebote con paso por tiempo real. Cada rebote cambia el color y + dispara el sonido; caer en una esquina es el momento especial: + deja esa esquina encendida con el color del golpe. + ============================================================ */ + +(() => { + 'use strict'; + + const CONFIG = { + /** Velocidad base en px/s, medida sobre una pantalla de 1080px de alto. */ + baseSpeed: 195, + /** + * Qué tan cerca del borde perpendicular tiene que caer un rebote para + * contar como esquina. Es una fracción del lado menor de la ventana, no + * un valor fijo: con píxeles absolutos la esquina es inalcanzable en + * pantallas grandes y el audio no llega a sonar nunca. + */ + cornerModes: [ + { name: 'Real', ratio: 0.008, min: 8 }, + { name: 'Normal', ratio: 0.045, min: 16 }, + { name: 'Generosa', ratio: 0.110, min: 32 } + ], + /** Tamaño del sprite como fracción del lado menor de la ventana. */ + sizeRatio: 0.17, + minSize: 88, + maxSize: 280, + /** Salto de matiz por rebote. El ángulo áureo evita colores repetidos. */ + hueStep: 137.508, + /** Voces en turnos: el sonido suena en cada rebote y pueden solaparse. */ + audioVoices: 6, + audioSrc: 'mambo_audio.ogg', + colorModes: ['tint', 'solid', 'off'], + colorModeNames: { tint: 'Tinte', solid: 'Sólido', off: 'Original' } + }; + + // ── Referencias del DOM ────────────────────────────────── + + const el = { + stage: document.getElementById('stage'), + sprite: document.getElementById('sprite'), + art: document.getElementById('spriteArt'), + solid: document.getElementById('spriteSolid'), + impactRing: document.getElementById('impactRing'), + flash: document.getElementById('flash'), + pausedTag: document.getElementById('pausedTag'), + hud: document.getElementById('hud'), + keys: document.getElementById('keys'), + boot: document.getElementById('boot'), + bootPlay: document.getElementById('bootPlay'), + statCorners: document.getElementById('statCorners'), + statBounces: document.getElementById('statBounces'), + statSpeed: document.getElementById('statSpeed'), + statMode: document.getElementById('statMode'), + statAim: document.getElementById('statAim'), + audioAlert: document.getElementById('audioAlert'), + corners: { + tl: document.querySelector('.corner[data-corner="tl"]'), + tr: document.querySelector('.corner[data-corner="tr"]'), + br: document.querySelector('.corner[data-corner="br"]'), + bl: document.querySelector('.corner[data-corner="bl"]') + } + }; + + const root = document.documentElement; + + // ── Estado ─────────────────────────────────────────────── + + const state = { + x: 0, y: 0, + vx: 0, vy: 0, + size: 180, + maxX: 0, maxY: 0, + hue: Math.random() * 360, + bounces: 0, + corners: 0, + speedMul: 1, + colorMode: 0, + cornerMode: 1, + running: false, + paused: false, + lastTime: 0 + }; + + // ── Audio ──────────────────────────────────────────────── + + const voices = Array.from({ length: CONFIG.audioVoices }, () => { + const a = new Audio(CONFIG.audioSrc); + a.preload = 'auto'; + return a; + }); + let voiceIndex = 0; + + /** + * Los navegadores exigen un gesto del usuario antes del primer play(). + * Se reproduce en silencio y se deja terminar: llamar a pause() aquí + * abortaba el golpe real si caía una esquina dentro de esa ventana, y el + * sonido se perdía con un AbortError. + */ + function unlockAudio() { + voices.forEach(a => { + a.muted = true; + a.addEventListener('ended', () => { a.muted = false; rewind(a); }, { once: true }); + a.play().catch(() => { a.muted = false; }); + }); + } + + function playHit() { + const a = voices[voiceIndex]; + voiceIndex = (voiceIndex + 1) % voices.length; + a.muted = false; + a.volume = 1; + rewind(a); + a.play().then( + () => el.audioAlert.classList.remove('is-live'), + () => el.audioAlert.classList.add('is-live') + ); + } + + /** currentTime lanza si el medio aún no tiene datos cargados. */ + function rewind(a) { + try { a.currentTime = 0; } catch { /* aún sin buffer, se reproduce igual */ } + } + + // ── Medidas ────────────────────────────────────────────── + + function measure() { + const vw = window.innerWidth; + const vh = window.innerHeight; + + const prevMaxX = state.maxX; + const prevMaxY = state.maxY; + + state.size = clamp(Math.min(vw, vh) * CONFIG.sizeRatio, CONFIG.minSize, CONFIG.maxSize); + root.style.setProperty('--sprite-size', `${state.size}px`); + + state.maxX = Math.max(0, vw - state.size); + state.maxY = Math.max(0, vh - state.size); + + // Conserva la posición relativa al redimensionar + if (prevMaxX > 0) state.x = (state.x / prevMaxX) * state.maxX; + if (prevMaxY > 0) state.y = (state.y / prevMaxY) * state.maxY; + + state.x = clamp(state.x, 0, state.maxX); + state.y = clamp(state.y, 0, state.maxY); + + applySpeed(); + paint(); + } + + /** Reescala el vector de velocidad al alto de la ventana y al multiplicador. */ + function applySpeed() { + const target = CONFIG.baseSpeed + * Math.max(0.55, window.innerHeight / 1080) + * state.speedMul; + + const current = Math.hypot(state.vx, state.vy); + if (current === 0) return; + + state.vx = (state.vx / current) * target; + state.vy = (state.vy / current) * target; + } + + // ── Arranque ───────────────────────────────────────────── + + function seed() { + measure(); + + state.x = state.maxX * (0.2 + Math.random() * 0.6); + state.y = state.maxY * (0.2 + Math.random() * 0.6); + + // Cerca de 45°, pero nunca exacto: así la trayectoria no se vuelve periódica + const angle = (22 + Math.random() * 46) * Math.PI / 180; + state.vx = Math.cos(angle) * (Math.random() < 0.5 ? -1 : 1); + state.vy = Math.sin(angle) * (Math.random() < 0.5 ? -1 : 1); + + applySpeed(); + paint(); + setHue(state.hue); + } + + // ── Bucle ──────────────────────────────────────────────── + + function frame(now) { + if (!state.running) return; + + // El clamp evita saltos gigantes al volver de una pestaña en segundo plano + const dt = Math.min((now - state.lastTime) / 1000, 0.05); + state.lastTime = now; + + if (!state.paused && dt > 0) step(dt); + + requestAnimationFrame(frame); + } + + function step(dt) { + state.x += state.vx * dt; + state.y += state.vy * dt; + + let hitX = false; + let hitY = false; + + if (state.x <= 0) { state.x = 0; state.vx = -state.vx; hitX = true; } + else if (state.x >= state.maxX) { state.x = state.maxX; state.vx = -state.vx; hitX = true; } + + if (state.y <= 0) { state.y = 0; state.vy = -state.vy; hitY = true; } + else if (state.y >= state.maxY) { state.y = state.maxY; state.vy = -state.vy; hitY = true; } + + paint(); + + if (!hitX && !hitY) return; + + state.bounces++; + setHue(state.hue + CONFIG.hueStep); + el.statBounces.textContent = state.bounces; + playHit(); + + if (isCornerHit(hitX, hitY)) onCorner(); + } + + /** + * Esquina exacta = rebote simultáneo en ambos ejes, algo casi imposible. + * Se acepta también un rebote lateral que ocurra dentro de la tolerancia + * del borde perpendicular: así el momento llega, pero sigue costando. + */ + function isCornerHit(hitX, hitY) { + const tol = cornerTolerance(); + if (hitX && hitY) return true; + if (hitX && (state.y <= tol || state.y >= state.maxY - tol)) return true; + if (hitY && (state.x <= tol || state.x >= state.maxX - tol)) return true; + return false; + } + + /** Tolerancia en px, relativa al tamaño real de la ventana. */ + function cornerTolerance() { + const mode = CONFIG.cornerModes[state.cornerMode]; + const shortSide = Math.min(window.innerWidth, window.innerHeight); + return Math.max(mode.min, shortSide * mode.ratio); + } + + function paint() { + el.sprite.style.transform = `translate3d(${state.x.toFixed(2)}px, ${state.y.toFixed(2)}px, 0)`; + } + + function setHue(value) { + state.hue = ((value % 360) + 360) % 360; + root.style.setProperty('--hue', state.hue.toFixed(1)); + if (CONFIG.colorModes[state.colorMode] === 'solid') paintSolid(); + } + + /** + * Rellena la silueta con un color plano recortando el alpha del PNG. + * source-in solo compone, nunca lee píxeles, así que el canvas + * contaminado por file:// no supone ningún problema. + */ + function paintSolid() { + const img = el.art; + if (!img.complete || img.naturalWidth === 0) return; + + const c = el.solid; + const ctx = c.getContext('2d'); + ctx.clearRect(0, 0, c.width, c.height); + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(img, 0, 0, c.width, c.height); + ctx.globalCompositeOperation = 'source-in'; + ctx.fillStyle = `hsl(${state.hue.toFixed(1)} 96% 58%)`; + ctx.fillRect(0, 0, c.width, c.height); + ctx.globalCompositeOperation = 'source-over'; + } + + // ── Momento de esquina ─────────────────────────────────── + + function onCorner() { + state.corners++; + el.statCorners.textContent = state.corners; + + restartAnimation(el.statCorners, 'is-bumped'); + restartAnimation(el.flash, 'is-live'); + + const key = cornerKey(); + const cx = key.includes('r') ? window.innerWidth : 0; + const cy = key.startsWith('b') ? window.innerHeight : 0; + + el.impactRing.style.left = `${cx}px`; + el.impactRing.style.top = `${cy}px`; + restartAnimation(el.impactRing, 'is-live'); + + const mark = el.corners[key]; + if (mark) { + mark.style.setProperty('--claimed-hue', state.hue.toFixed(1)); + mark.classList.add('is-claimed'); + restartAnimation(mark, 'is-hit'); + } + } + + /** Cuadrante ocupado por el sprite en el momento del impacto. */ + function cornerKey() { + const vertical = state.y < state.maxY / 2 ? 't' : 'b'; + const horizontal = state.x < state.maxX / 2 ? 'l' : 'r'; + return vertical + horizontal; + } + + function restartAnimation(node, className) { + node.classList.remove(className); + void node.offsetWidth; // fuerza reflow para poder relanzar la animación + node.classList.add(className); + } + + // ── Controles ──────────────────────────────────────────── + + function start() { + if (state.running) return; + el.boot.classList.add('is-gone'); + unlockAudio(); + state.running = true; + state.lastTime = performance.now(); + requestAnimationFrame(frame); + revealKeys(); + } + + function togglePause() { + state.paused = !state.paused; + el.pausedTag.classList.toggle('is-live', state.paused); + } + + function cycleColorMode() { + state.colorMode = (state.colorMode + 1) % CONFIG.colorModes.length; + const mode = CONFIG.colorModes[state.colorMode]; + el.sprite.dataset.color = mode; + el.statMode.textContent = CONFIG.colorModeNames[mode]; + if (mode === 'solid') paintSolid(); + } + + function cycleCornerMode() { + state.cornerMode = (state.cornerMode + 1) % CONFIG.cornerModes.length; + el.statAim.textContent = CONFIG.cornerModes[state.cornerMode].name; + } + + function nudgeSpeed(delta) { + state.speedMul = clamp(Number((state.speedMul + delta).toFixed(2)), 0.25, 4); + applySpeed(); + el.statSpeed.textContent = `${state.speedMul.toFixed(1)}×`; + } + + function resetRun() { + state.bounces = 0; + state.corners = 0; + el.statBounces.textContent = '0'; + el.statCorners.textContent = '0'; + Object.values(el.corners).forEach(c => { + c.classList.remove('is-claimed', 'is-hit'); + c.style.removeProperty('--claimed-hue'); + }); + seed(); + } + + function toggleHud() { + const hidden = el.hud.classList.toggle('is-hidden'); + if (hidden) { + clearTimeout(keysTimer); + el.keys.classList.add('is-hidden'); + } else { + revealKeys(); + } + } + + function toggleFullscreen() { + if (document.fullscreenElement) document.exitFullscreen(); + else document.documentElement.requestFullscreen().catch(() => {}); + } + + // ── Entrada ────────────────────────────────────────────── + + el.bootPlay.addEventListener('click', start); + + el.stage.addEventListener('click', () => { + if (state.running) togglePause(); + }); + + window.addEventListener('keydown', e => { + if (e.repeat) return; + + if (!state.running) { + if (e.code === 'Space' || e.code === 'Enter') { e.preventDefault(); start(); } + return; + } + + switch (e.code) { + case 'Space': e.preventDefault(); togglePause(); break; + case 'KeyC': cycleColorMode(); break; + case 'KeyE': cycleCornerMode(); break; + case 'KeyS': playHit(); break; + case 'KeyF': toggleFullscreen(); break; + case 'KeyR': resetRun(); break; + case 'KeyH': toggleHud(); break; + case 'Equal': + case 'NumpadAdd': nudgeSpeed(0.25); break; + case 'Minus': + case 'NumpadSubtract': nudgeSpeed(-0.25); break; + } + }); + + window.addEventListener('resize', measure); + + // El cursor estorba en un salvapantallas: reaparece solo al moverlo. + let cursorTimer; + window.addEventListener('mousemove', () => { + el.stage.classList.add('is-active'); + clearTimeout(cursorTimer); + cursorTimer = setTimeout(() => el.stage.classList.remove('is-active'), 2000); + revealKeys(); + }); + + let keysTimer; + /** La leyenda vuelve con la actividad y se retira sola al quedarse quieto. */ + function revealKeys() { + if (el.hud.classList.contains('is-hidden')) return; + el.keys.classList.remove('is-hidden'); + clearTimeout(keysTimer); + keysTimer = setTimeout(() => el.keys.classList.add('is-hidden'), 6000); + } + + window.addEventListener('keydown', revealKeys); + + // ── Utilidades ─────────────────────────────────────────── + + function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); + } + + // ── Init ───────────────────────────────────────────────── + + // El canvas solo puede pintarse cuando el PNG ya está decodificado + if (el.art.complete) paintSolid(); + else el.art.addEventListener('load', paintSolid, { once: true }); + + seed(); + el.statSpeed.textContent = `${state.speedMul.toFixed(1)}×`; + el.statMode.textContent = CONFIG.colorModeNames[CONFIG.colorModes[state.colorMode]]; + el.statAim.textContent = CONFIG.cornerModes[state.cornerMode].name; +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..940e0f3 --- /dev/null +++ b/index.html @@ -0,0 +1,93 @@ + + + + + +Mambo DVD + + + + +
+ + + + + +
+ Peluche de Mambo rebotando por la pantalla + +
+ + + + + + + + + +
+ + + + + +

+ El navegador bloqueó el audio. Haz clic en la pantalla y vuelve a probar con S. +

+ + + + +
+
+

Salvapantallas

+

MamboDVD

+

Mambo cambia de color y suena en cada rebote. Cuando cae justo en una esquina, esa esquina queda encendida con el color del golpe. El navegador necesita este clic para poder reproducir sonido.

+ +
+
+ + + + diff --git a/mambo_audio.ogg b/mambo_audio.ogg new file mode 100644 index 0000000..3e49811 Binary files /dev/null and b/mambo_audio.ogg differ diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..64d0393 --- /dev/null +++ b/styles.css @@ -0,0 +1,485 @@ +/* ============================================================ + Mambo DVD — salvapantallas + Paleta y tipografía de OSD de reproductor de DVD (negro puro, + cian de menú, monoespaciada en versalitas con tracking ancho). + ============================================================ */ + +:root { + /* Color */ + --void: #000000; + --osd: #4de3ff; /* cian de menú */ + --osd-dim: #1c5c6e; + --claim: #ff3d9a; /* magenta: momento de esquina */ + --ink: #e9f6fa; + --ink-mute: #7c99a2; /* AA sobre negro al tamaño de 10-12px del OSD */ + + /* Tipografía */ + --mono: "Cascadia Mono", "Consolas", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + --track: 0.22em; + + /* Estado en vivo, lo escribe app.js */ + --hue: 0; + --sprite-size: 180px; + + /* Ritmo */ + --gutter: clamp(16px, 3.2vw, 40px); + --corner-size: clamp(28px, 5vw, 56px); + /* Las marcas de esquina tienen su propia banda: el HUD y la leyenda + arrancan pasado ese ancho para no taparlas al reclamarse. */ + --edge-inset: calc(var(--gutter) + var(--corner-size) + 20px); +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; + margin: 0; +} + +body { + background: var(--void); + color: var(--ink); + font-family: var(--mono); + overflow: hidden; + overscroll-behavior: none; + -webkit-font-smoothing: antialiased; +} + +/* ── Escenario ───────────────────────────────────────────── */ + +.stage { + position: fixed; + inset: 0; + overflow: hidden; + cursor: none; + background: var(--void); +} + +.stage.is-idle { cursor: none; } +.stage.is-active { cursor: default; } + +/* ── El sprite que rebota ────────────────────────────────── */ + +.sprite { + position: absolute; + top: 0; + left: 0; + width: var(--sprite-size); + height: var(--sprite-size); + will-change: transform; + transform: translate3d(0, 0, 0); + user-select: none; + -webkit-user-drag: none; +} + +.sprite__art, +.sprite__solid { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.sprite__art { + object-fit: contain; + pointer-events: none; + -webkit-user-drag: none; +} + +/* Silueta plana: el comportamiento literal del logo original de DVD. + Se pinta en canvas y no con mask-image, porque las máscaras CSS pasan + por CORS y en file:// (origen "null") el sprite quedaba invisible. */ +.sprite__solid { + filter: drop-shadow(0 0 26px hsl(var(--hue) 100% 58% / 0.5)); +} + +/* Modo TINTE (por defecto): conserva el peluche, gira su color */ +.sprite[data-color="tint"] .sprite__art { + filter: + saturate(1.9) + hue-rotate(calc(var(--hue) * 1deg)) + drop-shadow(0 0 28px hsl(var(--hue) 100% 60% / 0.42)); +} +.sprite[data-color="tint"] .sprite__solid { display: none; } + +/* Modo SÓLIDO */ +.sprite[data-color="solid"] .sprite__art { visibility: hidden; } + +/* Modo ORIGINAL: sin recoloreo */ +.sprite[data-color="off"] .sprite__art { + filter: drop-shadow(0 0 22px rgb(255 255 255 / 0.16)); +} +.sprite[data-color="off"] .sprite__solid { display: none; } + +/* ── Marcas de esquina (elemento firma) ──────────────────── */ + +.corners { position: absolute; inset: 0; pointer-events: none; } + +.corner { + --edge: 2px; + position: absolute; + width: var(--corner-size); + height: var(--corner-size); + border: 0 solid var(--osd-dim); + opacity: 0.5; + transition: opacity 400ms ease, border-color 400ms ease; +} + +.corner[data-corner="tl"] { top: var(--gutter); left: var(--gutter); + border-top-width: var(--edge); border-left-width: var(--edge); } +.corner[data-corner="tr"] { top: var(--gutter); right: var(--gutter); + border-top-width: var(--edge); border-right-width: var(--edge); } +.corner[data-corner="br"] { bottom: var(--gutter); right: var(--gutter); + border-bottom-width: var(--edge); border-right-width: var(--edge); } +.corner[data-corner="bl"] { bottom: var(--gutter); left: var(--gutter); + border-bottom-width: var(--edge); border-left-width: var(--edge); } + +/* Reclamada: se queda encendida con el color exacto que la golpeó */ +.corner.is-claimed { + --edge: 3px; + opacity: 1; + border-color: hsl(var(--claimed-hue) 96% 62%); + box-shadow: 0 0 18px -2px hsl(var(--claimed-hue) 96% 62% / 0.7); +} + +.corner.is-hit { animation: cornerHit 900ms cubic-bezier(0.16, 1, 0.3, 1); } + +@keyframes cornerHit { + 0% { transform: scale(1); } + 18% { transform: scale(2.1); } + 100% { transform: scale(1); } +} + +/* ── Impacto de esquina ──────────────────────────────────── */ + +.impact { + position: absolute; + inset: 0; + pointer-events: none; +} + +.impact__ring { + position: absolute; + top: 0; left: 0; + width: 22px; height: 22px; + margin: -11px 0 0 -11px; + border: 2px solid hsl(var(--hue) 100% 66%); + border-radius: 50%; + opacity: 0; +} + +.impact__ring.is-live { animation: ring 1100ms cubic-bezier(0.12, 0.8, 0.2, 1); } + +@keyframes ring { + 0% { opacity: 0.95; transform: scale(1); border-width: 3px; } + 100% { opacity: 0; transform: scale(46); border-width: 0.5px; } +} + +.flash { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + background: hsl(var(--hue) 100% 70%); + mix-blend-mode: screen; +} + +.flash.is-live { animation: flash 460ms ease-out; } + +@keyframes flash { + 0% { opacity: 0.34; } + 100% { opacity: 0; } +} + +/* ── Atmósfera CRT: muy contenida ────────────────────────── */ + +.grain, +.vignette { + position: absolute; + inset: 0; + pointer-events: none; +} + +.grain { + opacity: 0.045; + background: repeating-linear-gradient( + to bottom, + rgb(255 255 255 / 0.9) 0 1px, + transparent 1px 3px + ); +} + +.vignette { + background: radial-gradient( + ellipse 80% 70% at 50% 50%, + transparent 40%, + rgb(0 0 0 / 0.55) 100% + ); +} + +/* ── HUD ─────────────────────────────────────────────────── */ + +.hud { + position: fixed; + left: var(--edge-inset); + bottom: var(--gutter); + display: flex; + gap: clamp(20px, 3.6vw, 44px); + align-items: flex-end; + padding: 14px 20px 12px; + border-left: 2px solid var(--osd); + background: linear-gradient(90deg, rgb(6 22 27 / 0.82), rgb(6 22 27 / 0)); + backdrop-filter: blur(2px); + transition: opacity 320ms ease, transform 320ms ease; + pointer-events: none; +} + +.hud.is-hidden { + opacity: 0; + transform: translateY(8px); +} + +.hud__stat { + display: flex; + flex-direction: column; + gap: 6px; +} + +.hud__label { + font-size: 9px; + letter-spacing: var(--track); + text-transform: uppercase; + color: var(--ink-mute); +} + +.hud__value { + font-size: clamp(22px, 3.4vw, 32px); + line-height: 0.9; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--ink); +} + +.hud__value--word { + font-size: clamp(13px, 1.8vw, 16px); + letter-spacing: 0.1em; + text-transform: uppercase; + padding-bottom: 4px; +} + +/* La cuenta de esquinas es el número que importa */ +.hud__stat--lead .hud__value { + font-size: clamp(34px, 5.4vw, 54px); + color: var(--osd); + text-shadow: 0 0 22px hsl(191 100% 60% / 0.45); +} + +.hud__stat--lead .hud__label { color: var(--osd); } + +.hud__value.is-bumped { animation: bump 700ms cubic-bezier(0.16, 1, 0.3, 1); } + +@keyframes bump { + 0% { transform: scale(1); color: var(--claim); } + 22% { transform: scale(1.32); color: var(--claim); } + 100% { transform: scale(1); } +} + +/* ── Aviso de audio: solo se muestra cuando falla ────────── */ + +.audio-alert { + position: fixed; + top: var(--gutter); + left: 50%; + transform: translateX(-50%); + margin: 0; + padding: 12px 20px; + max-width: min(90vw, 620px); + border: 1px solid var(--claim); + background: rgb(28 3 15 / 0.92); + font-size: 11px; + line-height: 1.7; + letter-spacing: 0.08em; + color: #ffd7e9; + text-align: center; + opacity: 0; + visibility: hidden; + transition: opacity 260ms ease, visibility 260ms; +} + +.audio-alert.is-live { + opacity: 1; + visibility: visible; +} + +.audio-alert kbd { + font: inherit; + padding: 1px 6px; + border: 1px solid var(--claim); + color: var(--claim); +} + +/* ── Leyenda de teclas ───────────────────────────────────── */ + +.keys { + position: fixed; + right: var(--edge-inset); + bottom: var(--gutter); + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px 18px; + max-width: min(46vw, 520px); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-mute); + transition: opacity 700ms ease; + pointer-events: none; +} + +.keys.is-hidden { opacity: 0; } + +.keys kbd { + font: inherit; + padding: 2px 6px; + margin-right: 4px; + border: 1px solid var(--osd-dim); + color: var(--osd); +} + +/* ── Indicador de pausa ──────────────────────────────────── */ + +.paused { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 10px 22px; + border: 1px solid var(--osd); + font-size: 12px; + letter-spacing: var(--track); + text-transform: uppercase; + color: var(--osd); + background: rgb(0 0 0 / 0.6); + opacity: 0; + transition: opacity 200ms ease; + pointer-events: none; +} + +.paused.is-live { opacity: 1; } + +/* ── Portada de arranque ─────────────────────────────────── */ + +.boot { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: var(--gutter); + background: var(--void); + z-index: 10; + transition: opacity 520ms ease, visibility 520ms; +} + +.boot.is-gone { + opacity: 0; + visibility: hidden; +} + +.boot__panel { + width: min(560px, 100%); + padding: clamp(28px, 5vw, 48px); + border: 1px solid var(--osd-dim); + border-left: 3px solid var(--osd); + text-align: left; +} + +.boot__eyebrow { + margin: 0 0 22px; + font-size: 10px; + letter-spacing: 0.4em; + text-transform: uppercase; + color: var(--osd); +} + +.boot__title { + margin: 0 0 20px; + font-size: clamp(38px, 9vw, 76px); + line-height: 0.94; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.boot__title-dim { + display: block; + color: var(--osd-dim); + letter-spacing: 0.3em; + font-size: 0.42em; + margin-top: 12px; +} + +.boot__note { + margin: 0 0 32px; + max-width: 42ch; + font-size: 12px; + line-height: 1.85; + letter-spacing: 0.05em; + color: var(--ink-mute); + text-transform: none; +} + +.boot__play { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 15px 34px; + border: 1px solid var(--osd); + background: transparent; + font: inherit; + font-size: 13px; + font-weight: 700; + letter-spacing: var(--track); + text-transform: uppercase; + color: var(--osd); + cursor: pointer; + transition: background-color 180ms ease, color 180ms ease, box-shadow 180ms ease; +} + +.boot__play:hover, +.boot__play:focus-visible { + background: var(--osd); + color: var(--void); + box-shadow: 0 0 30px -4px var(--osd); +} + +.boot__play:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 4px; +} + +.boot__glyph { animation: blink 1.6s steps(1, end) infinite; } + +@keyframes blink { + 0%, 55% { opacity: 1; } + 56%, 99% { opacity: 0.25; } +} + +/* ── Accesibilidad ───────────────────────────────────────── */ + +/* El rebote es el contenido, así que se conserva. Lo que se + apaga es el ruido decorativo alrededor del impacto. */ +@media (prefers-reduced-motion: reduce) { + .impact__ring.is-live, + .corner.is-hit, + .hud__value.is-bumped, + .boot__glyph { animation: none; } + + .flash.is-live { animation: flash 200ms linear; } + .grain { display: none; } +} + +@media (max-width: 720px) { + .keys { display: none; } + .hud { gap: 18px; padding: 10px 14px 8px; } +}