Files
mambo/app.js
T

443 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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;
})();