Skip to content

Commit 8d3ca94

Browse files
committed
Initial commit of the project files
0 parents  commit 8d3ca94

File tree

16 files changed

+1149
-0
lines changed

16 files changed

+1149
-0
lines changed

App.tsx

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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;

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<div align="center">
2+
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
3+
</div>
4+
5+
# Run and deploy your AI Studio app
6+
7+
This contains everything you need to run your app locally.
8+
9+
View your app in AI Studio: https://ai.studio/apps/drive/1ODnDnd3PzROiH4qsaqhv1lfpxm7A_N7S
10+
11+
## Run Locally
12+
13+
**Prerequisites:** Node.js
14+
15+
16+
1. Install dependencies:
17+
`npm install`
18+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
19+
3. Run the app:
20+
`npm run dev`

0 commit comments

Comments
 (0)