| | <!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> |