Slot / index.html
Lashtw's picture
Update index.html
5dba1c3 verified
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>幸運抽獎機</title>
<!-- Load Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Confetti Keyframes: Start at bottom, burst up, then fall (simulating gravity) */
@keyframes confetti-burst-fall {
/* FIX 1: 確保起始可見 (opacity 1) 且從底部開始相對位移 */
0% { transform: translateY(0) translateX(0) rotate(0deg); opacity: 1; }
10% { transform: translateY(-40vh) translateX(var(--confetti-initial-x)) rotate(180deg); opacity: 1; } /* Initial upward burst */
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); }
}
/* NEW: Winner flash animation */
@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); }
}
/* Custom styles for the spinning wheel */
.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; /* Default: no transition */
}
.wheel {
width: 100%;
height: 100%;
border-radius: 50%;
position: relative;
transition: transform 6s cubic-bezier(0.2, 1, 0.3, 1); /* Slower, smoother deceleration */
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; /* Yellow arrow */
z-index: 20;
/* FIX 2: 移除 box-shadow,只保留 drop-shadow 修正方形陰影問題 */
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 { /* FIX 3: 縮小尺寸 */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px; /* 縮小 */
height: 20px; /* 縮小 */
border-radius: 50%;
background: #fcd34d; /* Yellow */
border: 4px solid #1f2937; /* Dark border to contrast */
box-shadow: 0 0 10px rgba(255, 255, 255, 0.4);
z-index: 19; /* 降低 Z-index,讓 Overlay 蓋在上面 */
pointer-events: none;
}
/* Overlay to display the winner text clearly in the center */
.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; /* FIX 3: 提高 Z-index,確保結果可見 */
}
.segment-text {
/* 讓文字可以跟著轉盤移動 */
transition: none !important;
}
/* Override Tailwind for custom scrollbar aesthetics */
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">
<!-- Left Side: Controls and Participants Input -->
<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>
<!-- Right Side: Slot Machine Display -->
<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>
<!-- NEW: 轉盤中央固定裝飾點 -->
<div class="wheel-center-dot"></div>
<!-- 轉盤本體 (Conic Gradient) -->
<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; // The current degrees the wheel has rotated
// Macaron/Pastel Color Palette
const segmentColors = [
'#ffc8dd', // Pastel Pink
'#a2e2ff', // Sky Blue
'#baffc9', // Mint Green
'#ffffba', // Pale Yellow
'#ffb3a7', // Coral
'#cbaacb', // Lavender
'#f9bb82', // Peach
'#9ed2be', // Light Teal
'#e0aaff', // Violet
'#a7c7e7' // Light Blue
];
const totalRotations = 5; // The number of full spins the wheel must complete
const spinDuration = 6000; // 6 seconds total spin time
const wheelRadius = 250; // Half of the 500px container width
// --- Sound Effect (Web Audio API) ---
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);
// Envelope (Attack, Decay, Release)
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);
// Pitch shift up for cheer
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);
}
}
// --- Confetti Effect ---
/**
* Creates and launches the confetti particles with burst effect.
*/
function createConfetti() {
const confettiContainer = document.body; // Use body for fixed positioning
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';
// Randomize size and shape (30% chance of being a long rectangle/line)
const width = Math.random() * 3 + 3; // 3 to 6 px
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)];
// Initial horizontal position (centered burst)
const startX = window.innerWidth / 2 + (Math.random() - 0.5) * 400;
c.style.left = `${startX}px`;
// FIX 1: 使用 100vh 確保起始點在視窗底部邊緣 (為了解決 confetti 不見的問題)
c.style.top = `100vh`;
const duration = Math.random() * 2 + 3; // 3 to 5 seconds
const delay = Math.random() * 0.5;
const endXOffset = (Math.random() - 0.5) * 500; // Scatter horizontally
const initialX = (Math.random() - 0.5) * 150; // Initial burst horizontal move
// Apply animation
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);
// Remove the element after animation ends
setTimeout(() => c.remove(), (duration + delay) * 1000);
}
}
// --- Core Setup & State Management ---
/**
* Parses the participants and generates the conic gradient for the wheel.
*/
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;
}
/**
* Generates the CSS conic gradient for the wheel based on participants.
*/
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];
// Add segment color start
if (index > 0) {
gradientString += `, ${color} ${currentAngle}deg`;
} else {
gradientString += `${color} ${currentAngle}deg`;
}
// Add segment color stop
currentAngle += anglePerSegment;
gradientString += `, ${color} ${currentAngle}deg`;
});
gradientString += ')';
spinningWheel.style.background = gradientString;
// Re-add text overlays
addSegmentTextOverlays(N, anglePerSegment);
}
/**
* Adds transparent overlays for text display on the segments.
*/
function addSegmentTextOverlays(N, anglePerSegment) {
// Remove old texts first
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); // Center of the segment
// Standard Cartesian angle adjustment (0 deg top clockwise -> 0 deg right counter-clockwise)
const angleInRadians = (centerAngle - 90) * (Math.PI / 180);
// Calculate horizontal (x) and vertical (y) translation distances
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%'; // Wide enough for text
textDiv.style.top = '50%';
textDiv.style.left = '50%';
// 1. translate(-50%, -50%): Centers the text div itself
// 2. translate(${x}px, ${y}px): Moves the centered text outwards from the center (0,0)
// 3. rotate(${centerAngle}deg): Rotates the text to be aligned radially
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);
});
}
/**
* Handles the secret keyboard input for cheating (invisible to users).
*/
function handleKeydown(event) {
if (isDrawing) return;
// Check if the key is a digit (0-9)
if (event.key >= '0' && event.key <= '9') {
if (!cheatingNumber) cheatingNumber = '';
// Max length for input buffer
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.');
}
}
// --- Wheel Control Functions ---
/**
* Starts the drawing process (spinning the wheel).
*/
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');
// 1. Determine the winner
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);
}
// 2. Calculate the final rotation angle
const N = participants.length;
const anglePerSegment = 360 / N;
const winnerStartAngle = winnerIndex * anglePerSegment;
const targetCenterAngle = winnerStartAngle + (anglePerSegment / 2);
const finalStopAngle = 360 - targetCenterAngle;
// Total rotation: Multiple full rotations + the precise stopping angle
const totalRotationDegrees = totalRotations * 360 + finalStopAngle;
// 3. Apply the rotation transition
// Temporarily disable transition for reset
spinningWheel.style.transition = 'none';
spinningWheel.style.transform = `rotate(0deg)`;
// Force redraw before applying the transition
spinningWheel.offsetHeight;
// Apply the transition and the final rotation
spinningWheel.style.transition = `transform ${spinDuration / 1000}s cubic-bezier(0.2, 1, 0.3, 1)`;
spinningWheel.style.transform = `rotate(${totalRotationDegrees}deg)`;
currentRotation = totalRotationDegrees;
// 4. End the draw after the transition finishes
setTimeout(() => {
finishLottery(finalWinnerId, winnerIndex);
}, spinDuration);
}
/**
* Finalizes the draw, snaps the result, and declares the winner.
*/
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;
}
// 1. 禁用 CSS transition
spinningWheel.style.transition = 'none';
// 2. 應用最終旋轉角度 (确保最终靜止位置正確)
spinningWheel.style.transform = `rotate(${currentRotation % 360}deg)`;
// 3. 顯示中獎者疊層
overlayText.textContent = `${winner.id} - ${winner.name}`;
winnerOverlay.classList.remove('opacity-0');
winnerOverlay.classList.add('opacity-100', 'animate-flash');
// NEW: Play sound effect and trigger confetti
playWinnerSound();
createConfetti();
// Remove flash class after animation finishes (0.5s * 3 = 1.5s)
setTimeout(() => {
winnerOverlay.classList.remove('animate-flash');
}, 1500);
cheatingNumber = null;
showStatus(`恭喜 ${winner.name} 中獎!`, 'bg-purple-100 text-purple-700', 5000);
}
/**
* Shows a temporary status message.
*/
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);
}
// --- Initialization ---
window.onload = () => {
// Add initial mock data
participantsInput.value = `1 艾倫
2 貝拉
3 查理
4 戴安
5 艾迪
6 芬妮
7 蓋瑞
8 海倫
9 伊恩
10 珍妮
11 凱文
12 莉莉`;
loadParticipants();
document.addEventListener('keydown', handleKeydown);
};
</script>
</body>
</html>