Created
September 4, 2025 01:06
-
-
Save josemalena/8e7b7cbb9728dd09e2b42a1dccac097e to your computer and use it in GitHub Desktop.
Untitled
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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