| <!DOCTYPE html> |
| <html lang="zh-Hant"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>幸運抽獎機</title> |
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| <style> |
| |
| @keyframes confetti-burst-fall { |
| |
| 0% { transform: translateY(0) translateX(0) rotate(0deg); opacity: 1; } |
| 10% { transform: translateY(-40vh) translateX(var(--confetti-initial-x)) rotate(180deg); opacity: 1; } |
| 100% { transform: translateY(100vh) translateX(var(--confetti-end-x-offset)) rotate(1080deg); opacity: 0.5; } |
| } |
| |
| @keyframes confetti-shake { |
| 0% { transform: translateX(0); } |
| 100% { transform: translateX(15px); } |
| } |
| |
| |
| @keyframes winner-flash { |
| 0%, 100% { box-shadow: 0 0 15px rgba(252, 211, 77, 0.8); background-color: rgba(31, 41, 55, 0.8); } |
| 50% { box-shadow: 0 0 40px rgba(252, 211, 77, 1); background-color: rgba(252, 211, 77, 0.9); } |
| } |
| |
| |
| .wheel-container { |
| position: relative; |
| width: 500px; |
| height: 500px; |
| margin: 0 auto; |
| border-radius: 50%; |
| overflow: hidden; |
| background: #1f2937; |
| box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); |
| transition: transform 0s; |
| } |
| |
| .wheel { |
| width: 100%; |
| height: 100%; |
| border-radius: 50%; |
| position: relative; |
| transition: transform 6s cubic-bezier(0.2, 1, 0.3, 1); |
| transform-origin: 50% 50%; |
| } |
| |
| .wheel-pointer { |
| position: absolute; |
| top: 0; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 0; |
| height: 0; |
| border-left: 15px solid transparent; |
| border-right: 15px solid transparent; |
| border-top: 30px solid #fcd34d; |
| z-index: 20; |
| |
| filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.5)) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3)); |
| transition: transform 0.1s; |
| } |
| |
| .wheel-center-dot { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 20px; |
| height: 20px; |
| border-radius: 50%; |
| background: #fcd34d; |
| border: 4px solid #1f2937; |
| box-shadow: 0 0 10px rgba(255, 255, 255, 0.4); |
| z-index: 19; |
| pointer-events: none; |
| } |
| |
| |
| .winner-display-overlay { |
| position: absolute; |
| top: 50%; |
| left: 50%; |
| transform: translate(-50%, -50%); |
| width: 80%; |
| height: 80%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| text-align: center; |
| pointer-events: none; |
| z-index: 25; |
| } |
| |
| .segment-text { |
| |
| transition: none !important; |
| } |
| |
| |
| textarea::-webkit-scrollbar { |
| width: 8px; |
| } |
| textarea::-webkit-scrollbar-thumb { |
| background-color: #fcd34d; |
| border-radius: 4px; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-100 min-h-screen p-4 sm:p-8 font-sans"> |
|
|
| <div id="app" class="max-w-6xl mx-auto"> |
| <h1 class="text-4xl font-extrabold text-gray-800 mb-6 text-center">🎁 幸運抽獎機 🎁</h1> |
|
|
| <div class="flex flex-col lg:flex-row gap-8"> |
| |
| <div class="lg:w-1/3 p-6 bg-white shadow-xl rounded-xl border border-gray-200"> |
| <h2 class="2xl font-semibold mb-4 text-gray-700">1. 參加者名單輸入</h2> |
| <p class="text-sm text-gray-500 mb-3">格式:座號 姓名 (每行一組)</p> |
| <textarea id="participantsInput" rows="10" class="w-full p-3 border border-gray-300 rounded-lg focus:ring-amber-500 focus:border-amber-500 text-sm font-mono" placeholder="範例: |
| 1 王小明 |
| 2 李大華 |
| 3 陳美玲 |
| ..."></textarea> |
|
|
| <button onclick="loadParticipants()" class="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded-lg shadow-md transition duration-200"> |
| 載入名單 (共 <span id="participantCount">0</span> 人) |
| </button> |
|
|
| <h2 class="text-2xl font-semibold mt-8 mb-4 text-gray-700">2. 抽獎控制</h2> |
| <div id="statusMessage" class="p-3 bg-red-100 text-red-700 rounded-lg text-sm mb-4 hidden"></div> |
|
|
| <button id="startButton" onclick="startDraw()" class="w-full bg-amber-500 hover:bg-amber-600 text-gray-900 font-bold py-3 px-4 rounded-lg shadow-lg transition duration-200 disabled:opacity-50" disabled> |
| 開始抽獎! |
| </button> |
| </div> |
|
|
| |
| <div class="lg:w-2/3 p-6 bg-white shadow-xl rounded-xl flex flex-col items-center"> |
| <h2 class="text-2xl font-semibold mb-8 text-gray-700 text-center">幸運轉盤模擬</h2> |
| |
| <div id="wheelContainer" class="wheel-container"> |
| |
| <div class="wheel-pointer"></div> |
| |
| <div class="wheel-center-dot"></div> |
| |
| |
| <div id="spinningWheel" class="wheel"></div> |
|
|
| |
| <div id="winnerOverlay" class="winner-display-overlay opacity-0 transition-opacity duration-1000"> |
| <span id="overlayText" class="text-5xl font-extrabold text-amber-400 bg-gray-800/80 backdrop-blur-sm p-4 rounded-xl shadow-2xl"></span> |
| </div> |
| </div> |
| |
| |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const participantsInput = document.getElementById('participantsInput'); |
| const participantCountSpan = document.getElementById('participantCount'); |
| const startButton = document.getElementById('startButton'); |
| const statusMessage = document.getElementById('statusMessage'); |
| const spinningWheel = document.getElementById('spinningWheel'); |
| const winnerOverlay = document.getElementById('winnerOverlay'); |
| const overlayText = document.getElementById('overlayText'); |
| |
| let participants = []; |
| let cheatingNumber = null; |
| let isDrawing = false; |
| let currentRotation = 0; |
| |
| |
| const segmentColors = [ |
| '#ffc8dd', |
| '#a2e2ff', |
| '#baffc9', |
| '#ffffba', |
| '#ffb3a7', |
| '#cbaacb', |
| '#f9bb82', |
| '#9ed2be', |
| '#e0aaff', |
| '#a7c7e7' |
| ]; |
| |
| const totalRotations = 5; |
| const spinDuration = 6000; |
| const wheelRadius = 250; |
| |
| |
| function playWinnerSound() { |
| try { |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| |
| const oscillator = audioContext.createOscillator(); |
| oscillator.type = 'triangle'; |
| oscillator.frequency.setValueAtTime(440, audioContext.currentTime); |
| |
| const gainNode = audioContext.createGain(); |
| gainNode.gain.setValueAtTime(0, audioContext.currentTime); |
| |
| |
| gainNode.gain.linearRampToValueAtTime(0.5, audioContext.currentTime + 0.05); |
| gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.2); |
| gainNode.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 0.5); |
| |
| |
| oscillator.frequency.exponentialRampToValueAtTime(880, audioContext.currentTime + 0.5); |
| |
| oscillator.connect(gainNode); |
| gainNode.connect(audioContext.destination); |
| |
| oscillator.start(); |
| oscillator.stop(audioContext.currentTime + 0.5); |
| } catch (e) { |
| console.warn("Web Audio API not supported or failed to start:", e); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| function createConfetti() { |
| const confettiContainer = document.body; |
| const colors = ['#ffc8dd', '#a2e2ff', '#baffc9', '#ffffba', '#e0aaff', '#fcd34d']; |
| const numConfetti = 100; |
| |
| for (let i = 0; i < numConfetti; i++) { |
| const c = document.createElement('div'); |
| c.style.position = 'fixed'; |
| c.style.borderRadius = '2px'; |
| c.style.zIndex = '1000'; |
| c.style.pointerEvents = 'none'; |
| |
| |
| const width = Math.random() * 3 + 3; |
| const height = Math.random() < 0.3 ? Math.random() * 12 + 6 : width; |
| |
| c.style.width = `${width}px`; |
| c.style.height = `${height}px`; |
| c.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]; |
| |
| |
| const startX = window.innerWidth / 2 + (Math.random() - 0.5) * 400; |
| c.style.left = `${startX}px`; |
| |
| c.style.top = `100vh`; |
| |
| const duration = Math.random() * 2 + 3; |
| const delay = Math.random() * 0.5; |
| const endXOffset = (Math.random() - 0.5) * 500; |
| const initialX = (Math.random() - 0.5) * 150; |
| |
| |
| c.style.animation = ` |
| confetti-burst-fall ${duration}s ease-in ${delay}s forwards, |
| confetti-shake ${Math.random() * 1 + 1}s infinite alternate |
| `; |
| c.style.setProperty('--confetti-end-x-offset', `${endXOffset}px`); |
| c.style.setProperty('--confetti-initial-x', `${initialX}px`); |
| |
| confettiContainer.appendChild(c); |
| |
| |
| setTimeout(() => c.remove(), (duration + delay) * 1000); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| function loadParticipants() { |
| participants = []; |
| |
| winnerOverlay.classList.remove('opacity-100', 'animate-flash'); |
| winnerOverlay.classList.add('opacity-0'); |
| cheatingNumber = null; |
| spinningWheel.style.transform = 'rotate(0deg)'; |
| currentRotation = 0; |
| |
| const lines = participantsInput.value.trim().split('\n').filter(line => line.trim() !== ''); |
| |
| lines.forEach(line => { |
| const parts = line.trim().split(/\s+/); |
| if (parts.length >= 2) { |
| const id = parseInt(parts[0], 10); |
| const name = parts.slice(1).join(' '); |
| |
| if (!isNaN(id)) { |
| participants.push({ id, name, display: `${id} - ${name}` }); |
| } |
| } |
| }); |
| |
| if (participants.length > 0) { |
| generateWheelSegments(); |
| startButton.disabled = false; |
| statusMessage.classList.add('hidden'); |
| } else { |
| startButton.disabled = true; |
| showStatus('請輸入有效的參加者名單。', 'bg-red-100 text-red-700'); |
| } |
| |
| participantCountSpan.textContent = participants.length; |
| } |
| |
| |
| |
| |
| function generateWheelSegments() { |
| const N = participants.length; |
| if (N === 0) { |
| spinningWheel.style.background = '#374151'; |
| return; |
| } |
| |
| const anglePerSegment = 360 / N; |
| let gradientString = 'conic-gradient('; |
| let currentAngle = 0; |
| |
| participants.forEach((p, index) => { |
| const color = segmentColors[index % segmentColors.length]; |
| |
| |
| if (index > 0) { |
| gradientString += `, ${color} ${currentAngle}deg`; |
| } else { |
| gradientString += `${color} ${currentAngle}deg`; |
| } |
| |
| |
| currentAngle += anglePerSegment; |
| gradientString += `, ${color} ${currentAngle}deg`; |
| }); |
| |
| gradientString += ')'; |
| spinningWheel.style.background = gradientString; |
| |
| |
| addSegmentTextOverlays(N, anglePerSegment); |
| } |
| |
| |
| |
| |
| function addSegmentTextOverlays(N, anglePerSegment) { |
| |
| spinningWheel.querySelectorAll('.segment-text').forEach(el => el.remove()); |
| |
| |
| const baseFontSize = (N <= 10) ? '18px' : (N <= 20) ? '14px' : '12px'; |
| const textRadius = 0.85 * wheelRadius; |
| |
| participants.forEach((p, index) => { |
| const centerAngle = (index * anglePerSegment) + (anglePerSegment / 2); |
| |
| |
| const angleInRadians = (centerAngle - 90) * (Math.PI / 180); |
| |
| |
| const x = textRadius * Math.cos(angleInRadians); |
| const y = textRadius * Math.sin(angleInRadians); |
| |
| const textDiv = document.createElement('div'); |
| |
| textDiv.className = 'segment-text absolute text-gray-900 font-bold text-center'; |
| textDiv.textContent = p.display; |
| textDiv.style.fontSize = baseFontSize; |
| textDiv.style.width = '50%'; |
| textDiv.style.top = '50%'; |
| textDiv.style.left = '50%'; |
| |
| |
| |
| |
| textDiv.style.transform = ` |
| translate(-50%, -50%) |
| translate(${x}px, ${y}px) |
| rotate(${centerAngle}deg) |
| `; |
| textDiv.style.whiteSpace = 'nowrap'; |
| textDiv.style.userSelect = 'none'; |
| |
| spinningWheel.appendChild(textDiv); |
| }); |
| } |
| |
| |
| |
| |
| function handleKeydown(event) { |
| if (isDrawing) return; |
| |
| |
| if (event.key >= '0' && event.key <= '9') { |
| if (!cheatingNumber) cheatingNumber = ''; |
| |
| |
| if (cheatingNumber.length < 5) { |
| cheatingNumber += event.key; |
| console.log('Secret input buffer:', cheatingNumber); |
| } |
| } else if (event.key === 'Backspace' || event.key === 'Delete') { |
| if (cheatingNumber && cheatingNumber.length > 0) { |
| cheatingNumber = cheatingNumber.slice(0, -1); |
| console.log('Secret input buffer cleared one digit.'); |
| } |
| } else if (event.key === 'Escape') { |
| cheatingNumber = null; |
| console.log('Secret input cleared.'); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| function startDraw() { |
| if (isDrawing || participants.length === 0) return; |
| |
| isDrawing = true; |
| startButton.disabled = true; |
| startButton.textContent = '抽獎進行中...'; |
| winnerOverlay.classList.remove('opacity-100', 'animate-flash'); |
| winnerOverlay.classList.add('opacity-0'); |
| |
| |
| let finalWinnerId = cheatingNumber ? parseInt(cheatingNumber, 10) : null; |
| let winnerIndex = -1; |
| |
| if (finalWinnerId) { |
| winnerIndex = participants.findIndex(p => p.id === finalWinnerId); |
| if (winnerIndex === -1) { |
| finalWinnerId = null; |
| showStatus(`⚠️ 錯誤: 指定號碼 ${cheatingNumber} 不存在。執行隨機抽獎。`, 'bg-red-100 text-red-700', spinDuration); |
| } else { |
| showStatus(`抽獎進行中...`, 'bg-green-100 text-green-700', spinDuration); |
| } |
| } |
| |
| if (!finalWinnerId) { |
| const randomIndex = Math.floor(Math.random() * participants.length); |
| finalWinnerId = participants[randomIndex].id; |
| winnerIndex = randomIndex; |
| showStatus(`隨機抽獎進行中...`, 'bg-blue-100 text-blue-700', spinDuration); |
| } |
| |
| |
| const N = participants.length; |
| const anglePerSegment = 360 / N; |
| |
| const winnerStartAngle = winnerIndex * anglePerSegment; |
| const targetCenterAngle = winnerStartAngle + (anglePerSegment / 2); |
| const finalStopAngle = 360 - targetCenterAngle; |
| |
| |
| const totalRotationDegrees = totalRotations * 360 + finalStopAngle; |
| |
| |
| |
| |
| spinningWheel.style.transition = 'none'; |
| spinningWheel.style.transform = `rotate(0deg)`; |
| |
| |
| spinningWheel.offsetHeight; |
| |
| |
| spinningWheel.style.transition = `transform ${spinDuration / 1000}s cubic-bezier(0.2, 1, 0.3, 1)`; |
| spinningWheel.style.transform = `rotate(${totalRotationDegrees}deg)`; |
| |
| currentRotation = totalRotationDegrees; |
| |
| |
| setTimeout(() => { |
| finishLottery(finalWinnerId, winnerIndex); |
| }, spinDuration); |
| } |
| |
| |
| |
| |
| function finishLottery(finalWinnerId, winnerIndex) { |
| isDrawing = false; |
| startButton.disabled = false; |
| startButton.textContent = '重新抽獎'; |
| |
| const winner = participants.find(p => p.id === finalWinnerId); |
| |
| if (!winner) { |
| showStatus('抽獎結果無效,請檢查名單。', 'bg-red-100 text-red-700', 5000); |
| return; |
| } |
| |
| |
| spinningWheel.style.transition = 'none'; |
| |
| |
| spinningWheel.style.transform = `rotate(${currentRotation % 360}deg)`; |
| |
| |
| overlayText.textContent = `${winner.id} - ${winner.name}`; |
| winnerOverlay.classList.remove('opacity-0'); |
| winnerOverlay.classList.add('opacity-100', 'animate-flash'); |
| |
| |
| playWinnerSound(); |
| createConfetti(); |
| |
| |
| setTimeout(() => { |
| winnerOverlay.classList.remove('animate-flash'); |
| }, 1500); |
| |
| cheatingNumber = null; |
| showStatus(`恭喜 ${winner.name} 中獎!`, 'bg-purple-100 text-purple-700', 5000); |
| } |
| |
| |
| |
| |
| function showStatus(msg, className, duration = 3000) { |
| statusMessage.textContent = msg; |
| statusMessage.className = `p-3 rounded-lg text-sm mb-4 ${className}`; |
| statusMessage.classList.remove('hidden'); |
| setTimeout(() => { |
| statusMessage.classList.add('hidden'); |
| }, duration); |
| } |
| |
| |
| |
| window.onload = () => { |
| |
| participantsInput.value = `1 艾倫 |
| 2 貝拉 |
| 3 查理 |
| 4 戴安 |
| 5 艾迪 |
| 6 芬妮 |
| 7 蓋瑞 |
| 8 海倫 |
| 9 伊恩 |
| 10 珍妮 |
| 11 凱文 |
| 12 莉莉`; |
| |
| loadParticipants(); |
| document.addEventListener('keydown', handleKeydown); |
| }; |
| </script> |
|
|
| </body> |
| </html> |