Skip to content

Instantly share code, notes, and snippets.

@josemalena
Created September 4, 2025 01:06
Show Gist options
  • Select an option

  • Save josemalena/8e7b7cbb9728dd09e2b42a1dccac097e to your computer and use it in GitHub Desktop.

Select an option

Save josemalena/8e7b7cbb9728dd09e2b42a1dccac097e to your computer and use it in GitHub Desktop.
Untitled
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Pantalla de Ganador - Slot Machine + Redoble</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root{
--bg1:#0f172a; --bg2:#1e293b; --gold:#ffd54a; --accent:#ff5e57;
--reel-width: 90px; --reel-height: 120px; --reel-gap: 12px;
--border:#ffd54a; --name-size-clamp: clamp(28px, 6vw, 72px);
--reel-font-size: clamp(44px, 8vw, 72px);
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial;
background: radial-gradient(1200px 600px at 50% -10%, #243b55 10%, var(--bg1) 60%), linear-gradient(135deg,var(--bg1),var(--bg2));
color:#fff; overflow:hidden;}
.stage{height:100%;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:min(4vh,40px);padding:24px;position:relative;text-align:center;}
.logo-badge{letter-spacing:.12em;font-weight:800;color: var(--gold);text-shadow:0 0 14px rgba(255,213,74,.45);opacity:.9;user-select:none;}
.slot{display:flex;gap:var(--reel-gap);align-items:center;justify-content:center;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));box-shadow:0 10px 40px rgba(0,0,0,.45), inset 0 0 0 2px rgba(255,255,255,.05);}
.reel{width:var(--reel-width);height:var(--reel-height);border:3px solid var(--border);border-radius:12px;overflow:hidden;background:radial-gradient(120% 100% at 50% -20%, rgba(255,255,255,.25), transparent 40%), #000;position:relative;isolation:isolate;}
.reel::after{content:"";position:absolute;inset:0;box-shadow: inset 0 14px 20px -14px rgba(255,255,255,.6), inset 0 -18px 22px -16px rgba(0,0,0,.8);pointer-events:none;z-index:2;}
.strip{position:absolute;left:0;right:0;top:0;will-change: transform;}
.digit{height:var(--reel-height);display:flex;align-items:center;justify-content:center;font-weight:900;color: var(--gold);font-size: var(--reel-font-size);text-shadow:0 2px 0 #000, 0 0 16px rgba(255,213,74,.35);user-select:none;}
.winner{font-size: var(--name-size-clamp);font-weight:900;letter-spacing:.04em;text-transform:uppercase;opacity:0;transform: scale(.6) translateY(10px);filter: drop-shadow(0 6px 14px rgba(0,0,0,.4));
background: linear-gradient(90deg, #fff, #ffe082, #fff);-webkit-background-clip:text;background-clip:text;color:transparent;white-space:pre-wrap;line-height:1.08;padding:0 .1em;}
.winner.is-visible{animation: popIn .9s cubic-bezier(.2,1.1,.2,1) forwards, glow 2.2s ease-in-out 1s infinite alternate;}
@keyframes popIn{from{opacity:0; transform: scale(.6) translateY(10px)} to{opacity:1; transform: scale(1) translateY(0)}}
@keyframes glow{from{ text-shadow: 0 0 14px rgba(255,213,74,.35), 0 0 30px rgba(255,94,87,.15)} to{ text-shadow: 0 0 26px rgba(255,213,74,.8), 0 0 60px rgba(255,94,87,.35)}}
.actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:center;}
button{border:none;border-radius:10px;padding:10px 14px;font-weight:700;background:#0ea5e9;color:#031321;cursor:pointer;box-shadow:0 6px 24px rgba(14,165,233,.35);}
button:disabled{opacity:.6;cursor:not-allowed}
.status{min-height:22px;font-size:14px;opacity:.85;}
canvas#confetti{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;}
.slot,.winner,.actions,.status,.logo-badge{z-index:1;}
/* Botón de sonido */
.sound-toggle{background:#22c55e;color:#052e16}
.sound-toggle.muted{background:#64748b;color:#0b1320}
</style>
</head>
<body>
<canvas id="confetti"></canvas>
<main class="stage" aria-live="polite" aria-atomic="true">
<div class="logo-badge">🎉 RESULTADO OFICIAL DE LA RIFA</div>
<section class="slot" id="slot" aria-label="Número ganador estilo tragamonedas" role="group"></section>
<h1 id="winnerName" class="winner" aria-label="Nombre del ganador"></h1>
<div class="actions">
<button id="reloadBtn" title="Volver a consultar">🔄 Reintentar</button>
<button id="soundBtn" class="sound-toggle muted" title="Activar/Desactivar sonido">🔇 Sonido</button>
</div>
<div id="status" class="status"></div>
</main>
<script>
;(() => {
// =============== CONFIG ===============
const USE_DEMO = true; // Cambia a false para usar tu API real
const API_URL = "/api/winner"; // Debe responder { winningNumber: "0527", winnerName: "Juan Pérez" }
// Animación
const MIN_EXTRA_SPINS = 8;
const MAX_EXTRA_SPINS = 14;
const BASE_SPIN_DURATION = 1200; // ms
const SPIN_STAGGER = 180; // ms
// Elements
const slotEl = document.getElementById('slot');
const nameEl = document.getElementById('winnerName');
const statusEl = document.getElementById('status');
const reloadBtn = document.getElementById('reloadBtn');
const soundBtn = document.getElementById('soundBtn');
// =============== Utils ===============
const sleep = (ms) => new Promise(res => setTimeout(res, ms));
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
function setStatus(msg, isError=false){ statusEl.textContent = msg || ''; statusEl.style.color = isError ? '#ffb4ab' : '#aee3ff'; }
// =============== Audio Engine (Web Audio) ===============
class AudioEngine {
constructor(){
this.enabled = false; // se vuelve true cuando el usuario habilita
this.ctx = null;
this.masterGain = null;
this.activeRoll = null;
}
async init(){
if(this.ctx) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.8;
this.masterGain.connect(this.ctx.destination);
}
async enable(){
await this.init();
if(this.ctx.state === 'suspended') await this.ctx.resume();
this.enabled = true;
}
async disable(){
if(!this.ctx) return;
this.enabled = false;
await this.stopDrumroll(true);
await this.ctx.suspend();
}
// ruido blanco
_whiteNoiseBuffer(){
const sr = this.ctx.sampleRate;
const length = sr * 2; // 2s
const buffer = this.ctx.createBuffer(1, length, sr);
const data = buffer.getChannelData(0);
for(let i=0;i<length;i++) data[i] = Math.random()*2-1;
return buffer;
}
// Redoble: ruido blanco -> lowpass -> compuerta -> gain con crescendo
async playDrumroll(){
if(!this.enabled) return;
await this.init();
// si ya hay uno, reiniciar
if(this.activeRoll) await this.stopDrumroll();
const noise = this.ctx.createBufferSource();
noise.buffer = this._whiteNoiseBuffer();
noise.loop = true;
const lowpass = this.ctx.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = 400; // sonido "papery"
lowpass.Q.value = 0.6;
const highpass = this.ctx.createBiquadFilter();
highpass.type = 'highpass';
highpass.frequency.value = 80; // quitar graves feos
const rollGain = this.ctx.createGain();
rollGain.gain.value = 0.0001; // inicia silencioso
// Cadena
noise.connect(highpass);
highpass.connect(lowpass);
lowpass.connect(rollGain);
rollGain.connect(this.masterGain);
const now = this.ctx.currentTime;
// Crescendo suave 2.5s + leve vibración (tremolo manual con setValueCurveAtTime)
rollGain.gain.cancelScheduledValues(now);
rollGain.gain.setValueAtTime(0.0001, now);
rollGain.gain.exponentialRampToValueAtTime(0.5, now + 2.5);
// LFO tremolo (opcional con oscilador)
const lfo = this.ctx.createOscillator();
const lfoGain = this.ctx.createGain();
lfo.frequency.value = 8; // 8 Hz
lfoGain.gain.value = 0.25; // profundidad
lfo.connect(lfoGain);
lfoGain.connect(rollGain.gain);
lfo.start();
noise.start();
this.activeRoll = { noise, rollGain, lfo, lfoGain };
}
async stopDrumroll(immediate=false){
if(!this.activeRoll || !this.ctx) return;
const { noise, rollGain, lfo } = this.activeRoll;
const now = this.ctx.currentTime;
try{
if(immediate){
rollGain.gain.cancelScheduledValues(now);
rollGain.gain.setValueAtTime(0.0001, now);
} else {
rollGain.gain.cancelScheduledValues(now);
rollGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.15);
await new Promise(r => setTimeout(r, 160));
}
}finally{
try{ noise.stop(); }catch{}
try{ lfo.stop(); }catch{}
this.activeRoll = null;
}
}
// Crash: ruido blanco filtrado + “ting” de oscilador -> decaimiento rápido
async playCrash(){
if(!this.enabled){ return; }
await this.init();
const now = this.ctx.currentTime;
// Componente de ruido (cymbal)
const noise = this.ctx.createBufferSource();
noise.buffer = this._whiteNoiseBuffer();
const bandpass = this.ctx.createBiquadFilter();
bandpass.type = 'bandpass';
bandpass.frequency.setValueAtTime(6000, now);
bandpass.Q.value = 0.9;
const hipass = this.ctx.createBiquadFilter();
hipass.type = 'highpass';
hipass.frequency.setValueAtTime(1200, now);
const crashGain = this.ctx.createGain();
crashGain.gain.setValueAtTime(0.8, now);
crashGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.2);
noise.connect(bandpass);
bandpass.connect(hipass);
hipass.connect(crashGain);
crashGain.connect(this.masterGain);
// Componente “ting” (oscillator metálico)
const osc = this.ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(900, now);
osc.frequency.exponentialRampToValueAtTime(540, now + 0.25);
const oscGain = this.ctx.createGain();
oscGain.gain.setValueAtTime(0.5, now);
oscGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.6);
osc.connect(oscGain).connect(this.masterGain);
noise.start(now);
osc.start(now);
noise.stop(now + 1.25);
osc.stop(now + 0.65);
}
}
const audio = new AudioEngine();
// =============== Fetch ===============
async function fetchWinnerData(){
if(!USE_DEMO){
const r = await fetch(API_URL, { headers:{'Accept':'application/json'} });
if(!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
// DEMO local
await sleep(600 + Math.random()*700);
const demoNumbers = ["7","27","527","0527","90210","00042"];
const demoNames = ["Juan Pérez","María Gómez","Carlos Santana","Ana Rodríguez","Sofía López"];
return {
winningNumber: demoNumbers[randomInt(0, demoNumbers.length-1)],
winnerName: demoNames[randomInt(0, demoNames.length-1)]
};
}
// =============== Slot Machine ===============
function createReel(){
const reel = document.createElement('div'); reel.className = 'reel';
const strip = document.createElement('div'); strip.className = 'strip';
const DIGITS = [...Array(10).keys()];
const repeats = 4;
for (let r=0; r<repeats; r++){
for (const d of DIGITS){
const cell = document.createElement('div');
cell.className = 'digit'; cell.textContent = d;
strip.appendChild(cell);
}
}
reel.appendChild(strip);
return { reel, strip };
}
function setStripTransform(strip, y){ strip.style.transform = `translateY(${y}px)`; }
function cellHeight(){
const h = getComputedStyle(document.documentElement).getPropertyValue('--reel-height').trim();
return parseFloat(h);
}
function animateY(el, from, to, duration, easing='linear'){
return new Promise(resolve=>{
el.animate([{ transform:`translateY(${from}px)` }, { transform:`translateY(${to}px)` }], {
duration, easing, fill:'forwards'
}).addEventListener('finish', resolve, { once:true });
});
}
async function spinToDigit(strip, target, index){
const h = cellHeight();
const totalCells = strip.children.length;
const cellsPerCycle = 10;
const totalCycles = Math.floor(totalCells / cellsPerCycle);
const targetRow = (totalCycles - 1) * cellsPerCycle + target;
const extraSpins = randomInt(MIN_EXTRA_SPINS, MAX_EXTRA_SPINS);
setStripTransform(strip, - (0) * h);
const travelCells = targetRow + extraSpins * cellsPerCycle;
const duration = BASE_SPIN_DURATION + index * SPIN_STAGGER + randomInt(120, 260);
const startY = 0; const endY = - travelCells * h;
await animateY(strip, startY, endY, duration, 'cubic-bezier(.12,.6,0,1)');
await animateY(strip, endY, endY + h*0.18, 120, 'ease-out');
await animateY(strip, endY + h*0.18, endY, 140, 'ease-in');
}
async function runSlot(winningNumber){
// Build reels
slotEl.innerHTML = '';
const digits = String(winningNumber).split('').map(ch => {
if(!/^\d$/.test(ch)) throw new Error('El número ganador debe contener solo dígitos 0-9');
return parseInt(ch,10);
});
const reels = digits.map(()=>createReel());
for(const {reel} of reels) slotEl.appendChild(reel);
// Pre-spin
await Promise.all(reels.map(({strip}, i)=>{
const h = cellHeight(); const pre = -h * randomInt(10,20);
return animateY(strip, 0, pre, 260 + i*60, 'linear');
}));
// 🔊 Comienza redoble
audio.playDrumroll();
// Spin to digits
await Promise.all(reels.map(({strip}, i)=>spinToDigit(strip, digits[i], i)));
// 🔊 Paramos redoble y crash
await audio.stopDrumroll();
audio.playCrash();
}
// =============== Confeti (Canvas) ===============
function launchConfetti(durationMs = 3800, particles = 200){
const canvas = document.getElementById('confetti');
const ctx = canvas.getContext('2d');
let w, h, animationId, startTime;
function resize(){ w = canvas.width = window.innerWidth * devicePixelRatio; h = canvas.height = window.innerHeight * devicePixelRatio; }
resize(); window.addEventListener('resize', resize);
const colors = ['#FF6B6B','#FFD93D','#6BCB77','#4D96FF','#FFD54A','#F72585','#B8F2E6'];
const parts = Array.from({length:particles}, () => ({
x: Math.random()*w, y: -Math.random()*h*0.5, r: (6 + Math.random()*10) * devicePixelRatio,
c: colors[Math.floor(Math.random()*colors.length)], vY: (1.2 + Math.random()*3.2) * devicePixelRatio,
vX: (Math.random()*2 -1) * devicePixelRatio * 1.2, a: Math.random()*Math.PI*2, spin: (Math.random()*0.2 - 0.1)
}));
function tick(ts){
if(!startTime) startTime = ts;
const elapsed = ts - startTime;
ctx.clearRect(0,0,w,h);
for(const p of parts){
p.a += p.spin; p.x += p.vX; p.y += p.vY;
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.a);
ctx.fillStyle = p.c; const rw = p.r * (0.6 + Math.sin(p.a)*0.2 + 0.2); const rh = p.r * 0.5;
ctx.fillRect(-rw/2, -rh/2, rw, rh); ctx.restore();
if(p.y > h + 40*devicePixelRatio) { p.y = -20*devicePixelRatio; p.x = Math.random()*w; }
}
if(elapsed < durationMs){ animationId = requestAnimationFrame(tick); }
else { cancelAnimationFrame(animationId); ctx.clearRect(0,0,w,h); window.removeEventListener('resize', resize); }
}
animationId = requestAnimationFrame(tick);
}
// =============== Flow principal ===============
async function render(){
try{
setStatus('Consultando ganador…');
reloadBtn.disabled = true;
nameEl.classList.remove('is-visible');
nameEl.textContent = '';
const { winningNumber, winnerName } = await fetchWinnerData();
setStatus(`Número obtenido: ${winningNumber} — Mostrando animación…`);
await runSlot(winningNumber);
// Mostrar nombre y confeti
nameEl.textContent = `🏆 ${winnerName} 🏆`;
nameEl.classList.add('is-visible');
launchConfetti();
setStatus('¡Felicitaciones al ganador! 🎊');
} catch (err){
console.error(err);
setStatus('Error al obtener el ganador. Reintenta.', true);
// aseguramos detener redoble si hubo error en medio
await audio.stopDrumroll(true);
} finally {
reloadBtn.disabled = false;
}
}
// Botones
reloadBtn.addEventListener('click', async () => {
// Intento de reactivar audio context por gesto de usuario
if(audio.enabled && audio.ctx && audio.ctx.state === 'suspended') { await audio.ctx.resume(); }
render();
});
soundBtn.addEventListener('click', async () => {
if(!audio.enabled){
await audio.enable();
soundBtn.classList.remove('muted');
soundBtn.textContent = '🔊 Sonido';
setStatus('Sonido activado. 🥁', false);
} else {
await audio.disable();
soundBtn.classList.add('muted');
soundBtn.textContent = '🔇 Sonido';
setStatus('Sonido desactivado.', false);
}
});
// Inicio automático (sin sonido hasta que el usuario lo habilite)
render();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment