1+ import React , { useState , useCallback , useRef , useEffect } from 'react' ;
2+ import { Player } from './types' ;
3+ import { INITIAL_PLAYERS , WHEEL_COLORS } from './constants' ;
4+ import Wheel from './components/Wheel' ;
5+ import Controls from './components/Controls' ;
6+ import WinnerModal from './components/WinnerModal' ;
7+ import { Play , Zap , History , Trophy } from 'lucide-react' ;
8+ import { playTick , playWin , playSpinStart } from './utils/audio' ;
9+
10+ // Helper for cubic-bezier(0.1, 0, 0.18, 1) approximation
11+ // We need this to calculate where the wheel IS during the JS loop to play sounds correctly
12+ function cubicBezier ( t : number ) : number {
13+ const p0 = 0 , p1 = 0 , p2 = 0.18 , p3 = 1 ; // Simplified for the specific curve
14+ // Since x1=0.1, y1=0 and x2=0.18, y2=1.
15+ // This is a custom approximation for performance:
16+ // It starts very fast and decays exponentially.
17+ // 1 - (1-t)^4 is a standard EaseOutQuart which is close to the CSS look
18+ return 1 - Math . pow ( 1 - t , 4 ) ;
19+ }
20+
21+ const App : React . FC = ( ) => {
22+ const [ players , setPlayers ] = useState < Player [ ] > (
23+ INITIAL_PLAYERS . map ( ( name , i ) => ( {
24+ id : crypto . randomUUID ( ) ,
25+ name,
26+ color : WHEEL_COLORS [ i % WHEEL_COLORS . length ] ,
27+ } ) )
28+ ) ;
29+
30+ const [ rotation , setRotation ] = useState ( 0 ) ;
31+ const [ isSpinning , setIsSpinning ] = useState ( false ) ;
32+ const [ winner , setWinner ] = useState < Player | null > ( null ) ;
33+ const [ history , setHistory ] = useState < Player [ ] > ( [ ] ) ;
34+ const [ eliminationMode , setEliminationMode ] = useState ( false ) ;
35+
36+ // Audio state refs
37+ const lastTickRef = useRef < number > ( 0 ) ;
38+ const animationFrameRef = useRef < number > ( 0 ) ;
39+
40+ const wheelContainerRef = useRef < HTMLDivElement > ( null ) ;
41+ const [ wheelSize , setWheelSize ] = useState ( 300 ) ;
42+
43+ useEffect ( ( ) => {
44+ const handleResize = ( ) => {
45+ if ( wheelContainerRef . current ) {
46+ const { width, height } = wheelContainerRef . current . getBoundingClientRect ( ) ;
47+ const isMobile = window . innerWidth < 1024 ;
48+ const dimension = isMobile ? width : Math . min ( width , height ) ;
49+ const padding = isMobile ? 40 : 40 ;
50+ setWheelSize ( ( dimension / 2 ) - padding ) ;
51+ }
52+ } ;
53+
54+ const debouncedResize = ( ) => requestAnimationFrame ( handleResize ) ;
55+
56+ window . addEventListener ( 'resize' , debouncedResize ) ;
57+ handleResize ( ) ;
58+
59+ return ( ) => window . removeEventListener ( 'resize' , debouncedResize ) ;
60+ } , [ ] ) ;
61+
62+ // Keyboard support (Spacebar to spin)
63+ useEffect ( ( ) => {
64+ const handleKeyDown = ( e : KeyboardEvent ) => {
65+ if ( e . code === 'Space' && ! isSpinning && ! winner && players . length >= 2 ) {
66+ // Prevent scrolling down
67+ e . preventDefault ( ) ;
68+ handleSpin ( ) ;
69+ }
70+ } ;
71+ window . addEventListener ( 'keydown' , handleKeyDown ) ;
72+ return ( ) => window . removeEventListener ( 'keydown' , handleKeyDown ) ;
73+ } , [ isSpinning , winner , players ] ) ;
74+
75+ const handleAddPlayer = ( name : string , color : string ) => {
76+ setPlayers ( ( prev ) => [ ...prev , { id : crypto . randomUUID ( ) , name, color } ] ) ;
77+ } ;
78+
79+ const handleRemovePlayer = ( id : string ) => {
80+ setPlayers ( ( prev ) => prev . filter ( ( p ) => p . id !== id ) ) ;
81+ } ;
82+
83+ const handleReset = ( ) => {
84+ if ( confirm ( "Reset all players?" ) ) {
85+ setPlayers (
86+ INITIAL_PLAYERS . map ( ( name , i ) => ( {
87+ id : crypto . randomUUID ( ) ,
88+ name,
89+ color : WHEEL_COLORS [ i % WHEEL_COLORS . length ] ,
90+ } ) )
91+ ) ;
92+ setHistory ( [ ] ) ;
93+ setWinner ( null ) ;
94+ }
95+ } ;
96+
97+ const handleSpin = useCallback ( ( ) => {
98+ if ( isSpinning || players . length < 2 ) return ;
99+
100+ playSpinStart ( ) ;
101+ setWinner ( null ) ;
102+ setIsSpinning ( true ) ;
103+
104+ if ( window . innerWidth < 1024 ) {
105+ window . scrollTo ( { top : 0 , behavior : 'smooth' } ) ;
106+ }
107+
108+ // CSS Animation Duration
109+ const duration = 10000 ;
110+
111+ const spinCount = 8 ; // More spins for dramatic effect
112+ const randomDegree = Math . floor ( Math . random ( ) * 360 ) ;
113+ const startRotation = rotation ;
114+ const targetRotation = startRotation + ( 360 * spinCount ) + randomDegree ;
115+
116+ setRotation ( targetRotation ) ;
117+
118+ // Audio Sync Loop
119+ // We simulate the rotation in JS to trigger sounds when passing pegs
120+ const startTime = performance . now ( ) ;
121+ const segmentSize = 360 / players . length ;
122+
123+ const tick = ( ) => {
124+ const now = performance . now ( ) ;
125+ const elapsed = now - startTime ;
126+ const progress = Math . min ( elapsed / duration , 1 ) ;
127+
128+ // Calculate current virtual rotation based on easing
129+ const easedProgress = cubicBezier ( progress ) ;
130+ const currentRotation = startRotation + ( targetRotation - startRotation ) * easedProgress ;
131+
132+ // Check if we passed a segment boundary (peg)
133+ // We check if the integer division of segment size changed
134+ // Offset by -90 because the pointer is at the top
135+ const currentTickIndex = Math . floor ( ( currentRotation ) / segmentSize ) ;
136+
137+ if ( currentTickIndex > lastTickRef . current ) {
138+ // Only play if moving fast enough (don't click on the very last slow creep)
139+ if ( progress < 0.98 ) {
140+ playTick ( ) ;
141+ }
142+ lastTickRef . current = currentTickIndex ;
143+ }
144+
145+ if ( progress < 1 ) {
146+ animationFrameRef . current = requestAnimationFrame ( tick ) ;
147+ }
148+ } ;
149+
150+ lastTickRef . current = Math . floor ( startRotation / segmentSize ) ;
151+ cancelAnimationFrame ( animationFrameRef . current ) ;
152+ animationFrameRef . current = requestAnimationFrame ( tick ) ;
153+
154+ } , [ rotation , isSpinning , players . length ] ) ;
155+
156+ const handleSpinEnd = ( ) => {
157+ setIsSpinning ( false ) ;
158+ cancelAnimationFrame ( animationFrameRef . current ) ;
159+
160+ const actualRotation = rotation % 360 ;
161+ const sliceAngle = 360 / players . length ;
162+
163+ const winningIndex = Math . floor ( ( players . length - ( actualRotation / sliceAngle ) % players . length ) ) % players . length ;
164+
165+ const winPlayer = players [ winningIndex ] ;
166+ setWinner ( winPlayer ) ;
167+ setHistory ( prev => [ winPlayer , ...prev ] . slice ( 0 , 5 ) ) ;
168+
169+ playWin ( ) ;
170+ } ;
171+
172+ const handleModalClose = ( ) => {
173+ setWinner ( null ) ;
174+ if ( eliminationMode && winner ) {
175+ handleRemovePlayer ( winner . id ) ;
176+ }
177+ } ;
178+
179+ return (
180+ < div className = "min-h-screen lg:h-screen bg-[#020617] text-slate-200 flex flex-col lg:flex-row lg:overflow-hidden font-sans selection:bg-red-500 selection:text-white relative" >
181+ < div className = "scanlines" > </ div >
182+ < div className = "fixed inset-0 z-0 opacity-10 pointer-events-none"
183+ style = { { backgroundImage : 'radial-gradient(#475569 1px, transparent 1px)' , backgroundSize : '40px 40px' } } >
184+ </ div >
185+
186+ { /* LEFT PANEL */ }
187+ < div className = "relative w-full h-auto lg:h-full lg:flex-1 bg-gradient-to-br from-slate-950 via-[#050b1d] to-slate-950 flex flex-col items-center justify-start lg:justify-center p-0 lg:p-0 order-1 lg:order-1 shrink-0" >
188+ < div className = "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-fuchsia-900/10 rounded-full blur-[100px] pointer-events-none fixed lg:absolute" > </ div >
189+
190+ < div className = "lg:hidden w-full text-center pt-10 pb-2 z-20 relative" >
191+ < h1 className = "text-4xl font-display font-black text-white tracking-tight neon-text italic" > ASMODEUS</ h1 >
192+ </ div >
193+
194+ < div ref = { wheelContainerRef } className = "w-full flex items-center justify-center relative z-10 py-8 lg:py-0 lg:h-full min-h-[350px]" >
195+ < Wheel
196+ players = { players }
197+ rotation = { rotation }
198+ radius = { Math . max ( 100 , wheelSize ) }
199+ onSpinEnd = { handleSpinEnd }
200+ isSpinning = { isSpinning }
201+ />
202+ </ div >
203+
204+ < div className = "absolute bottom-2 left-4 text-[10px] font-mono text-slate-700 hidden lg:block" >
205+ SYS.VER.2.1.0 // AUDIO_ENABLED
206+ </ div >
207+ </ div >
208+
209+ { /* RIGHT PANEL */ }
210+ < div className = "w-full h-auto lg:h-full lg:w-[450px] bg-slate-900/95 border-t lg:border-t-0 lg:border-l border-slate-800 flex flex-col z-20 shadow-2xl order-2 lg:order-2 backdrop-blur-md relative" >
211+
212+ < div className = "p-3 lg:p-6 border-b border-slate-800 bg-slate-950/50 hidden lg:block" >
213+ < div className = "flex items-center gap-3 mb-2" >
214+ < div className = "w-2 h-2 bg-red-500 rounded-full animate-pulse shadow-[0_0_10px_red]" > </ div >
215+ < h2 className = "text-xs font-mono text-red-500 tracking-[0.3em]" > COMMAND CENTER</ h2 >
216+ </ div >
217+ < h1 className = "text-4xl font-display font-black text-white tracking-tight neon-text italic" >
218+ ASMODEUS
219+ </ h1 >
220+ </ div >
221+
222+ < div className = "flex-none lg:flex-1 lg:overflow-y-auto custom-scrollbar flex flex-col p-4 lg:p-6 gap-4" >
223+
224+ < div className = "w-full" >
225+ < Controls
226+ players = { players }
227+ onAddPlayer = { handleAddPlayer }
228+ onRemovePlayer = { handleRemovePlayer }
229+ onReset = { handleReset }
230+ isSpinning = { isSpinning }
231+ eliminationMode = { eliminationMode }
232+ setEliminationMode = { setEliminationMode }
233+ />
234+ </ div >
235+
236+ { history . length > 0 && (
237+ < div className = "hidden lg:block bg-slate-950/50 rounded-lg p-4 border border-slate-800 shrink-0" >
238+ < div className = "flex items-center gap-2 mb-3 text-slate-400" >
239+ < History className = "w-4 h-4" />
240+ < span className = "text-xs font-bold uppercase tracking-wider" > Recent Victories</ span >
241+ </ div >
242+ < div className = "space-y-2" >
243+ { history . map ( ( h , i ) => (
244+ < div key = { `${ h . id } -${ i } ` } className = "flex items-center justify-between text-sm" >
245+ < span className = "text-slate-300 font-display" > { h . name } </ span >
246+ { i === 0 && < Trophy className = "w-3 h-3 text-yellow-500" /> }
247+ </ div >
248+ ) ) }
249+ </ div >
250+ </ div >
251+ ) }
252+ </ div >
253+
254+ < div className = "p-4 lg:p-6 bg-slate-950 border-t border-slate-800 shrink-0 pb-safe-area sticky bottom-0 lg:relative z-30" >
255+ < button
256+ onClick = { handleSpin }
257+ disabled = { isSpinning || players . length < 2 }
258+ className = { `w-full relative overflow-hidden group py-4 lg:py-5 rounded-md font-display font-black text-lg lg:text-xl tracking-widest transition-all ${
259+ isSpinning || players . length < 2
260+ ? 'bg-slate-800 text-slate-600 cursor-not-allowed border border-slate-700'
261+ : 'bg-red-600 text-white hover:bg-red-500 shadow-[0_0_20px_rgba(220,38,38,0.4)] hover:shadow-[0_0_30px_rgba(220,38,38,0.6)] border border-red-500'
262+ } `}
263+ >
264+ < div className = "flex items-center justify-center gap-3 relative z-10" >
265+ { isSpinning ? (
266+ < >
267+ < Zap className = "w-5 h-5 animate-spin" /> < span className = "text-sm lg:text-xl" > PROCESSING</ span >
268+ </ >
269+ ) : (
270+ < >
271+ < Play fill = "currentColor" className = "w-5 h-5" /> < span className = "text-sm lg:text-xl" > INITIATE SPIN (SPACE)</ span >
272+ </ >
273+ ) }
274+ </ div >
275+ { ! isSpinning && players . length >= 2 && (
276+ < div className = "absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300" > </ div >
277+ ) }
278+ </ button >
279+
280+ < div className = "mt-2 lg:mt-3 flex justify-between text-[10px] text-slate-600 font-mono uppercase" >
281+ < span > Status: { isSpinning ? 'ACTIVE' : 'READY' } </ span >
282+ < span > { players . length } SOULS LOADED</ span >
283+ </ div >
284+ </ div >
285+
286+ </ div >
287+
288+ < WinnerModal winner = { winner } onClose = { handleModalClose } />
289+ </ div >
290+ ) ;
291+ } ;
292+
293+ export default App ;
0 commit comments