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