import React, { useState, useEffect, useRef } from 'react'; import { Volume2, VolumeX, RefreshCw, List, History, AlertCircle } from 'lucide-react'; export default function App() { // 初期状態 const [inputItems, setInputItems] = useState(() => { const saved = localStorage.getItem('rouletteItems'); return saved !== null ? saved : "1班\n2班\n3班\n4班\n5班\n6班"; }); const [items, setItems] = useState(() => { const saved = localStorage.getItem('rouletteItems'); if (saved !== null) return saved.split('\n').filter(item => item.trim() !== ''); return ["1班", "2班", "3班", "4班", "5班", "6班"]; }); const [allowDuplicate, setAllowDuplicate] = useState(() => { const saved = localStorage.getItem('rouletteAllowDup'); return saved !== null ? saved === 'true' : true; }); const [history, setHistory] = useState([]); const [initialCount, setInitialCount] = useState(items.length); const [isSpinning, setIsSpinning] = useState(false); const [soundOn, setSoundOn] = useState(true); const [rotation, setRotation] = useState(0); const [result, setResult] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); // 針のアニメーション用ステート const [needleRotation, setNeedleRotation] = useState(0); const audioCtxRef = useRef(null); const animationRef = useRef(null); const lastTickAngleRef = useRef(0); const getAudioCtx = () => { if (!audioCtxRef.current) { audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtxRef.current.state === 'suspended') { audioCtxRef.current.resume(); } return audioCtxRef.current; }; const playTick = () => { if (!soundOn) return; try { const ctx = getAudioCtx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(1000, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.05); gain.gain.setValueAtTime(0.08, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.05); } catch (e) { console.error(e) } }; const playTada = () => { if (!soundOn) return; try { const ctx = getAudioCtx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; [440, 554, 659].forEach((freq, i) => { osc.frequency.setValueAtTime(freq, ctx.currentTime + i * 0.1); }); gain.gain.setValueAtTime(0, ctx.currentTime); gain.gain.linearRampToValueAtTime(0.2, ctx.currentTime + 0.05); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1.2); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 1.2); } catch (e) { console.error(e) } }; useEffect(() => { localStorage.setItem('rouletteItems', inputItems); localStorage.setItem('rouletteAllowDup', allowDuplicate); }, [inputItems, allowDuplicate]); const handleInputChange = (e) => { const value = e.target.value; setInputItems(value); const newItems = value.split('\n').filter(item => item.trim() !== ''); setItems(newItems); setInitialCount(newItems.length); setRotation(0); setResult(null); }; // スピンアニメーションの実行 const spin = () => { if (items.length === 0 || isSpinning) return; setIsSpinning(true); setResult(null); const startAngle = rotation; const spins = 6 + Math.random() * 4; // 6〜10回転 const totalRotation = spins * 360; const startTime = performance.now(); const duration = 5000; // 5秒かけて回転 const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // イージング関数 (Quartic Out) const easeOut = 1 - Math.pow(1 - progress, 4); const currentRotation = startAngle + totalRotation * easeOut; setRotation(currentRotation); // 境界(ピン)を通過したかチェック const anglePerItem = 360 / items.length; // 12時の位置(-90度オフセット)を基準にする const currentRelativeAngle = (currentRotation) % 360; // どのセグメントにいるか計算し、境界を越えた瞬間に音を鳴らす const currentSegment = Math.floor(currentRelativeAngle / anglePerItem); const lastSegment = Math.floor(lastTickAngleRef.current / anglePerItem); if (currentSegment !== lastSegment) { playTick(); // 針を弾く(ピンが針を押し上げるモーション) setNeedleRotation(-25); lastTickAngleRef.current = currentRelativeAngle; } else { // 針が徐々に戻る setNeedleRotation(prev => prev * 0.85); } if (progress < 1) { animationRef.current = requestAnimationFrame(animate); } else { finishSpin(currentRotation); } }; animationRef.current = requestAnimationFrame(animate); }; const finishSpin = (finalRotation) => { setIsSpinning(false); setNeedleRotation(0); const anglePerItem = 360 / items.length; const normalizedRotation = finalRotation % 360; // 針は上(270度方向)にあるため、そこに来るインデックスを計算 const topPosAngle = (360 - normalizedRotation + 270) % 360; const selectedIndex = Math.floor(topPosAngle / anglePerItem) % items.length; const selected = items[selectedIndex]; setResult(selected); setHistory(prev => [selected, ...prev]); playTada(); if (!allowDuplicate) { const newItems = items.filter((_, i) => i !== selectedIndex); setItems(newItems); setInputItems(newItems.join('\n')); } }; const executeReset = () => { const defaultItems = "1班\n2班\n3班\n4班\n5班\n6班"; setInputItems(defaultItems); setItems(defaultItems.split('\n')); setInitialCount(6); setHistory([]); setResult(null); setRotation(0); localStorage.removeItem('rouletteItems'); localStorage.removeItem('rouletteAllowDup'); setShowResetConfirm(false); }; // SVG描画用 const colors = ['#fef3c7', '#fce7f3', '#e0f2fe', '#dcfce7', '#ffedd5', '#f3e8ff', '#e2e8f0', '#ffe4e6']; const radius = 180; const cx = 200; const cy = 200; const createPieSlice = (index, total) => { const startAngle = (index * 360) / total; const endAngle = ((index + 1) * 360) / total; const startRad = (startAngle * Math.PI) / 180; const endRad = (endAngle * Math.PI) / 180; const x1 = cx + radius * Math.cos(startRad); const y1 = cy + radius * Math.sin(startRad); const x2 = cx + radius * Math.cos(endRad); const y2 = cy + radius * Math.sin(endRad); const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0; return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`; }; return (
{/* リセット確認モーダル */} {showResetConfirm && (
setShowResetConfirm(false)}>

リセットの確認

リストと履歴を初期状態に戻しますか?

)}
{/* 左側: ルーレット表示エリア */}
{/* 針 (Pointer) */}
{items.length === 0 ? ( ) : ( items.map((item, i) => { const anglePerItem = 360 / items.length; const startAngle = i * anglePerItem; const textAngle = startAngle + anglePerItem / 2; const textRad = (textAngle * Math.PI) / 180; const tx = cx + radius * 0.65 * Math.cos(textRad); const ty = cy + radius * 0.65 * Math.sin(textRad); // ピンの座標(境界線上の外周) const pinRad = (startAngle * Math.PI) / 180; const px = cx + (radius - 5) * Math.cos(pinRad); const py = cy + (radius - 5) * Math.sin(pinRad); return ( 10 ? "14" : "22"} fontWeight="900" > {item} {/* ピン (障害物) */} ); }) )} {/* 外枠の輪 */}
{isSpinning ? (
抽選中...
) : result ? (
{result}
) : (
READY
)}
候補数: {items.length} / {initialCount}
{/* 右側: 操作エリア */}
項目リスト