/* ============================================================ 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; })();