Enhance mobile UI and multiplayer

- Hide terminal icon on mobile and add vertical scrollbar to sidebar
- Add robust radio API fallbacks for MusicContext
- Fix mobile dropdown to auto-close on route change
- Introduce 2-player multiplayer for arcade games (Snake 2P, prep for others)
- Update app structure to route snake-2p and keep intro/video flow intact

X-Lovable-Edit-ID: edt-a2080411-825e-495f-8e7e-9c2a15a36c9c
This commit is contained in:
gpt-engineer-app[bot]
2026-01-02 21:32:33 +00:00
6 changed files with 460 additions and 14 deletions
+2
View File
@@ -29,6 +29,7 @@ const Leaderboard = lazy(() => import("./pages/Leaderboard"));
const Tetris = lazy(() => import("./pages/Tetris")); const Tetris = lazy(() => import("./pages/Tetris"));
const Pacman = lazy(() => import("./pages/Pacman")); const Pacman = lazy(() => import("./pages/Pacman"));
const Snake = lazy(() => import("./pages/Snake")); const Snake = lazy(() => import("./pages/Snake"));
const SnakeMultiplayer = lazy(() => import("./pages/SnakeMultiplayer"));
const Breakout = lazy(() => import("./pages/Breakout")); const Breakout = lazy(() => import("./pages/Breakout"));
const Music = lazy(() => import("./pages/Music")); const Music = lazy(() => import("./pages/Music"));
const Oscilloscope = lazy(() => import("./pages/Oscilloscope")); const Oscilloscope = lazy(() => import("./pages/Oscilloscope"));
@@ -124,6 +125,7 @@ const AppContent = () => {
<Route path="games/tetris" element={<Tetris />} /> <Route path="games/tetris" element={<Tetris />} />
<Route path="games/pacman" element={<Pacman />} /> <Route path="games/pacman" element={<Pacman />} />
<Route path="games/snake" element={<Snake />} /> <Route path="games/snake" element={<Snake />} />
<Route path="games/snake-2p" element={<SnakeMultiplayer />} />
<Route path="games/breakout" element={<Breakout />} /> <Route path="games/breakout" element={<Breakout />} />
<Route path="faq" element={<FAQ />} /> <Route path="faq" element={<FAQ />} />
<Route path="music" element={<Music />} /> <Route path="music" element={<Music />} />
+8 -2
View File
@@ -29,6 +29,11 @@ const Sidebar = () => {
const currentPage = navItems.find(item => item.path === location.pathname)?.label || 'Menu'; const currentPage = navItems.find(item => item.path === location.pathname)?.label || 'Menu';
// Close mobile menu when route changes
useEffect(() => {
setIsExpanded(false);
}, [location.pathname]);
// Check if footer is visible within the sidebar // Check if footer is visible within the sidebar
useEffect(() => { useEffect(() => {
const checkFooterVisibility = () => { const checkFooterVisibility = () => {
@@ -77,7 +82,7 @@ const Sidebar = () => {
</button> </button>
{/* Desktop Navigation - Always visible */} {/* Desktop Navigation - Always visible */}
<nav ref={navRef} className="hidden md:flex flex-col flex-grow p-4 md:p-5"> <nav ref={navRef} className="hidden md:flex flex-col flex-grow p-4 md:p-5 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/50 scrollbar-track-transparent">
{navItems.map((item, index) => { {navItems.map((item, index) => {
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
@@ -140,8 +145,9 @@ const Sidebar = () => {
> >
<Link <Link
to={item.path} to={item.path}
onClick={() => { onClick={(e) => {
playSound('click'); playSound('click');
// Immediately close the menu
setIsExpanded(false); setIsExpanded(false);
}} }}
className={cn( className={cn(
+1 -1
View File
@@ -174,7 +174,7 @@ const TerminalCommand = () => {
setIsOpen(true); setIsOpen(true);
playSound('beep'); playSound('beep');
}} }}
className="fixed bottom-4 right-4 z-[60] p-3 border-2 border-primary bg-background text-primary hover:bg-primary hover:text-background transition-all duration-300 box-glow" className="hidden md:flex fixed bottom-4 right-4 z-[60] p-3 border-2 border-primary bg-background text-primary hover:bg-primary hover:text-background transition-all duration-300 box-glow"
title="Open Terminal (` or Ctrl+/)" title="Open Terminal (` or Ctrl+/)"
> >
<Terminal className="w-5 h-5" /> <Terminal className="w-5 h-5" />
+33 -4
View File
@@ -55,18 +55,47 @@ export const MusicProvider = ({ children }: { children: ReactNode }) => {
const fetchStations = useCallback(async () => { const fetchStations = useCallback(async () => {
if (hasFetched) return; if (hasFetched) return;
// Multiple radio-browser API servers for fallback
const apiServers = [
'https://de1.api.radio-browser.info',
'https://de2.api.radio-browser.info',
'https://nl1.api.radio-browser.info',
'https://nl2.api.radio-browser.info',
'https://at1.api.radio-browser.info',
'https://fr1.api.radio-browser.info',
'https://all.api.radio-browser.info',
];
for (const server of apiServers) {
try { try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch( const response = await fetch(
'https://de1.api.radio-browser.info/json/stations/topclick/100' `${server}/json/stations/topclick/100`,
{ signal: controller.signal }
); );
if (!response.ok) throw new Error('Failed to fetch stations'); clearTimeout(timeoutId);
if (!response.ok) continue;
const data: Station[] = await response.json(); const data: Station[] = await response.json();
const validStations = data.filter(s => !failedStations.has(s.stationuuid)); const validStations = data.filter(s => !failedStations.has(s.stationuuid));
if (validStations.length > 0) {
setStations(validStations); setStations(validStations);
setHasFetched(true); setHasFetched(true);
} catch (err) { console.log(`Fetched stations from ${server}`);
console.error('Error fetching stations:', err); return;
} }
} catch (err) {
console.warn(`Failed to fetch from ${server}:`, err);
continue;
}
}
console.error('All radio API servers failed');
}, [hasFetched, failedStations]); }, [hasFetched, failedStations]);
const stopCurrentAudio = useCallback(() => { const stopCurrentAudio = useCallback(() => {
+45
View File
@@ -51,6 +51,20 @@ const games = [
}, },
]; ];
const multiplayerGames = [
{
id: 'snake-2p',
name: 'Snake 2P',
description: '2 players on same keyboard - last one standing wins!',
ascii: `┌────────┐
│ ●■■ │
│ │
│ ▓▓● │
│ │
└────────┘`,
},
];
const Games = () => { const Games = () => {
return ( return (
<motion.div <motion.div
@@ -102,6 +116,37 @@ const Games = () => {
))} ))}
</div> </div>
{/* Multiplayer Section */}
<div className="mt-6">
<h2 className="font-minecraft text-xl text-primary text-glow mb-3">
<GlitchText text="Multiplayer" />
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{multiplayerGames.map((game, index) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 + index * 0.1 }}
>
<Link to={`/games/${game.id}`} className="block">
<div className="border border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 p-4 transition-all duration-300 group cursor-pointer">
<pre className="font-mono text-sm text-purple-400/70 group-hover:text-purple-400 transition-colors mb-3 leading-tight">
{game.ascii}
</pre>
<h2 className="font-minecraft text-xl text-purple-400 group-hover:text-glow-strong">
<GlitchText text={game.name} />
</h2>
<p className="font-pixel text-xs text-foreground/60 mt-2">
{game.description}
</p>
</div>
</Link>
</motion.div>
))}
</div>
</div>
<div className="border border-primary/30 p-2 bg-background/30 mt-4"> <div className="border border-primary/30 p-2 bg-background/30 mt-4">
<p className="font-pixel text-xs text-foreground/50"> <p className="font-pixel text-xs text-foreground/50">
<span className="text-primary">{'>'}</span> Max score: 4,294,967,296 <span className="text-primary">{'>'}</span> Max score: 4,294,967,296
+364
View File
@@ -0,0 +1,364 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { Link } from 'react-router-dom';
import { Maximize2, Minimize2 } from 'lucide-react';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
const GRID_SIZE = 20;
const TICK_SPEED = 120;
type Direction = 'up' | 'down' | 'left' | 'right';
type Position = { x: number; y: number };
interface Player {
snake: Position[];
direction: Direction;
score: number;
alive: boolean;
color: string;
name: string;
}
const SnakeMultiplayer = () => {
const { playSound } = useSettings();
const [players, setPlayers] = useState<[Player, Player]>([
{ snake: [{ x: 5, y: 10 }], direction: 'right', score: 0, alive: true, color: 'hsl(var(--primary))', name: 'P1' },
{ snake: [{ x: 15, y: 10 }], direction: 'left', score: 0, alive: true, color: 'hsl(280 70% 50%)', name: 'P2' },
]);
const [food, setFood] = useState<Position>({ x: 10, y: 10 });
const [gameOver, setGameOver] = useState(false);
const [winner, setWinner] = useState<string | null>(null);
const [gameStarted, setGameStarted] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const gameRef = useRef<HTMLDivElement>(null);
const directionQueues = useRef<[Direction[], Direction[]]>([[], []]);
const currentDirections = useRef<[Direction, Direction]>(['right', 'left']);
const { enterFullscreen, exitFullscreen } = useBrowserFullscreen();
const getCellSize = useCallback(() => {
if (typeof window === 'undefined') return 20;
const isMobile = window.innerWidth < 768;
if (isMobile) {
const maxWidth = window.innerWidth - 40;
const maxHeight = window.innerHeight - 300;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 16);
}
return isFullscreen ? 24 : 20;
}, [isFullscreen]);
const [cellSize, setCellSize] = useState(getCellSize);
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
useEffect(() => {
const handleResize = () => setCellSize(getCellSize());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [getCellSize]);
useEffect(() => {
setCellSize(getCellSize());
}, [isFullscreen, getCellSize]);
const toggleFullscreen = async () => {
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
else { setIsFullscreen(false); await exitFullscreen(); }
playSound('click');
};
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) { setIsFullscreen(false); exitFullscreen(); }
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [isFullscreen, exitFullscreen]);
const spawnFood = useCallback((snakes: Position[][]): Position => {
const occupied = new Set(snakes.flat().map(s => `${s.x},${s.y}`));
const available: Position[] = [];
for (let x = 0; x < GRID_SIZE; x++) {
for (let y = 0; y < GRID_SIZE; y++) {
if (!occupied.has(`${x},${y}`)) available.push({ x, y });
}
}
if (available.length === 0) return { x: 10, y: 10 };
return available[Math.floor(Math.random() * available.length)];
}, []);
const startGame = () => {
const p1Start = [{ x: 5, y: 10 }];
const p2Start = [{ x: 15, y: 10 }];
setPlayers([
{ snake: p1Start, direction: 'right', score: 0, alive: true, color: 'hsl(var(--primary))', name: 'P1' },
{ snake: p2Start, direction: 'left', score: 0, alive: true, color: 'hsl(280 70% 50%)', name: 'P2' },
]);
currentDirections.current = ['right', 'left'];
directionQueues.current = [[], []];
setFood(spawnFood([p1Start, p2Start]));
setGameOver(false);
setWinner(null);
setIsPaused(false);
setGameStarted(true);
playSound('success');
gameRef.current?.focus();
};
useEffect(() => {
if (!gameStarted || gameOver || isPaused) return;
const interval = setInterval(() => {
const opposite: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
setPlayers(prev => {
const newPlayers: [Player, Player] = [{ ...prev[0] }, { ...prev[1] }];
for (let i = 0; i < 2; i++) {
if (!newPlayers[i].alive) continue;
// Process direction queue
let nextDir = currentDirections.current[i];
while (directionQueues.current[i].length > 0) {
const queuedDir = directionQueues.current[i].shift()!;
if (queuedDir !== opposite[currentDirections.current[i]]) {
nextDir = queuedDir;
break;
}
}
currentDirections.current[i] = nextDir;
newPlayers[i].direction = nextDir;
// Move snake
const head = newPlayers[i].snake[0];
let newHead: Position;
switch (nextDir) {
case 'up': newHead = { x: head.x, y: head.y - 1 }; break;
case 'down': newHead = { x: head.x, y: head.y + 1 }; break;
case 'left': newHead = { x: head.x - 1, y: head.y }; break;
case 'right': newHead = { x: head.x + 1, y: head.y }; break;
}
// Check wall collision
if (newHead.x < 0 || newHead.x >= GRID_SIZE || newHead.y < 0 || newHead.y >= GRID_SIZE) {
newPlayers[i].alive = false;
playSound('error');
continue;
}
// Check self collision
if (newPlayers[i].snake.some(s => s.x === newHead.x && s.y === newHead.y)) {
newPlayers[i].alive = false;
playSound('error');
continue;
}
// Check collision with other snake
const otherIndex = i === 0 ? 1 : 0;
if (newPlayers[otherIndex].snake.some(s => s.x === newHead.x && s.y === newHead.y)) {
newPlayers[i].alive = false;
playSound('error');
continue;
}
const newSnake = [newHead, ...newPlayers[i].snake];
// Check food
if (newHead.x === food.x && newHead.y === food.y) {
playSound('success');
newPlayers[i].score += 10;
setFood(spawnFood([newPlayers[0].snake, newPlayers[1].snake]));
} else {
newSnake.pop();
}
newPlayers[i].snake = newSnake;
}
// Check win condition
const alive = newPlayers.filter(p => p.alive);
if (alive.length === 0) {
setGameOver(true);
setWinner('Draw!');
} else if (alive.length === 1) {
setGameOver(true);
setWinner(`${alive[0].name} Wins!`);
}
return newPlayers;
});
}, TICK_SPEED);
return () => clearInterval(interval);
}, [gameStarted, gameOver, isPaused, food, playSound, spawnFood]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!gameStarted) return;
// Player 1: WASD
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('up'); }
if (e.key === 's' || e.key === 'S') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('down'); }
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('left'); }
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); if (directionQueues.current[0].length < 3) directionQueues.current[0].push('right'); }
// Player 2: Arrow keys
if (e.key === 'ArrowUp') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('up'); }
if (e.key === 'ArrowDown') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('down'); }
if (e.key === 'ArrowLeft') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('left'); }
if (e.key === 'ArrowRight') { e.preventDefault(); if (directionQueues.current[1].length < 3) directionQueues.current[1].push('right'); }
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); if (!gameOver) setIsPaused(p => !p); }
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [gameStarted, gameOver]);
const renderGrid = () => {
const cells = [];
const p1Set = new Set(players[0].snake.map(s => `${s.x},${s.y}`));
const p2Set = new Set(players[1].snake.map(s => `${s.x},${s.y}`));
const p1Head = players[0].snake[0];
const p2Head = players[1].snake[0];
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
const isP1 = p1Set.has(`${x},${y}`);
const isP2 = p2Set.has(`${x},${y}`);
const isP1Head = p1Head.x === x && p1Head.y === y && players[0].alive;
const isP2Head = p2Head.x === x && p2Head.y === y && players[1].alive;
const isFood = food.x === x && food.y === y;
let bgClass = 'bg-background/50 border-primary/10';
let style: React.CSSProperties = { width: cellSize, height: cellSize };
if (isP1Head) {
bgClass = 'border-primary';
style.backgroundColor = players[0].color;
style.boxShadow = `0 0 10px ${players[0].color}`;
} else if (isP1 && players[0].alive) {
bgClass = 'border-primary/60';
style.backgroundColor = players[0].color;
style.opacity = 0.8;
} else if (isP2Head) {
bgClass = 'border-purple-500';
style.backgroundColor = players[1].color;
style.boxShadow = `0 0 10px ${players[1].color}`;
} else if (isP2 && players[1].alive) {
bgClass = 'border-purple-500/60';
style.backgroundColor = players[1].color;
style.opacity = 0.8;
} else if (isFood) {
bgClass = 'bg-destructive/80 border-destructive/60';
}
cells.push(
<div
key={`${x}-${y}`}
className={`flex items-center justify-center border transition-colors duration-75 ${bgClass}`}
style={style}
>
{isFood && <div className="bg-destructive rounded-sm animate-pulse" style={{ width: cellSize * 0.5, height: cellSize * 0.5 }} />}
</div>
);
}
}
return cells;
};
return (
<motion.div
ref={gameRef}
tabIndex={0}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4">
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Snake 2P</h1>
</div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize2 size={16} className="text-primary" /> : <Maximize2 size={16} className="text-primary" />}
</button>
</div>
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : ''}`}>
<div className="border-2 border-primary box-glow p-1 bg-background/80">
<div className="grid" style={{ gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)` }}>{renderGrid()}</div>
</div>
<div className="flex flex-col gap-2 min-w-[160px]">
{/* Player 1 Stats */}
<div className="border border-primary/50 p-3 bg-background/50">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[0].color }} />
<p className="font-pixel text-[10px] text-foreground/60">PLAYER 1 (WASD)</p>
</div>
<p className="font-minecraft text-lg text-primary text-glow">{players[0].score}</p>
{!players[0].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</p>}
</div>
{/* Player 2 Stats */}
<div className="border border-purple-500/50 p-3 bg-background/50">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: players[1].color }} />
<p className="font-pixel text-[10px] text-foreground/60">PLAYER 2 ()</p>
</div>
<p className="font-minecraft text-lg text-purple-400">{players[1].score}</p>
{!players[1].alive && <p className="font-pixel text-[8px] text-destructive">DEAD</p>}
</div>
<div className="border border-primary/50 p-3 bg-background/50">
<p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p>
<p className="font-pixel text-[9px] text-primary">P1: W A S D</p>
<p className="font-pixel text-[9px] text-purple-400">P2: </p>
<p className="font-pixel text-[9px] text-foreground/80 mt-1">P: Pause</p>
</div>
{!gameStarted || gameOver ? (
<button onClick={startGame} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{gameOver ? 'PLAY AGAIN' : 'START'}
</button>
) : (
<button onClick={() => setIsPaused(p => !p)} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">
{isPaused ? 'RESUME' : 'PAUSE'}
</button>
)}
</div>
</div>
{/* Game Over / Pause Overlay */}
{(gameOver || isPaused) && gameStarted && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">
{gameOver ? winner : 'PAUSED'}
</h2>
{gameOver && (
<div className="flex gap-4 justify-center mb-3">
<div>
<p className="font-pixel text-[10px] text-foreground/60">P1 Score</p>
<p className="font-minecraft text-lg text-primary">{players[0].score}</p>
</div>
<div>
<p className="font-pixel text-[10px] text-foreground/60">P2 Score</p>
<p className="font-minecraft text-lg text-purple-400">{players[1].score}</p>
</div>
</div>
)}
<button onClick={gameOver ? startGame : () => setIsPaused(false)} className="font-minecraft text-sm py-2 px-6 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
{gameOver ? 'PLAY AGAIN' : 'RESUME'}
</button>
</div>
</div>
)}
</motion.div>
);
};
export default SnakeMultiplayer;