Unified fullscreen across arcade games with Portal; added cursor rendering, pacman fruit bonuses, adjusted breakout speed, and 2P theming tweaks.

- Made arcade fullscreen use portal approach and render MatrixCursor inside fullscreen
- Added Pac-Man fruit bonuses spawning at center after certain dot counts
- Slowed Breakout ball speed for classic feel
- Adjusted 2P theming: swap secondary color with theme and ensure correct coloring on 2P UI

X-Lovable-Edit-ID: edt-2a44cfa1-a1f4-40ad-98df-f3491c2c6dc0
This commit is contained in:
gpt-engineer-app[bot]
2026-02-02 08:49:30 +00:00
6 changed files with 147 additions and 14 deletions
+4 -4
View File
@@ -40,8 +40,8 @@ html {
--primary: 120 100% 50%; --primary: 120 100% 50%;
--primary-foreground: 0 0% 0%; --primary-foreground: 0 0% 0%;
--secondary: 120 100% 15%; --secondary: 0 100% 50%;
--secondary-foreground: 120 100% 50%; --secondary-foreground: 0 0% 0%;
--muted: 120 20% 10%; --muted: 120 20% 10%;
--muted-foreground: 120 30% 60%; --muted-foreground: 120 30% 60%;
@@ -82,8 +82,8 @@ html {
--primary: 0 100% 50%; --primary: 0 100% 50%;
--primary-foreground: 0 0% 0%; --primary-foreground: 0 0% 0%;
--secondary: 0 100% 15%; --secondary: 120 100% 50%;
--secondary-foreground: 0 100% 50%; --secondary-foreground: 0 0% 0%;
--muted: 0 20% 10%; --muted: 0 20% 10%;
--muted-foreground: 0 30% 60%; --muted-foreground: 0 30% 60%;
+3
View File
@@ -12,6 +12,7 @@ import GlitchText from '@/components/GlitchText';
import MessageContent from '@/components/MessageContent'; import MessageContent from '@/components/MessageContent';
import { AI_PROVIDERS, AIProvider, getProvider, CUSTOM_API_STORAGE_KEY } from '@/lib/aiProviders'; import { AI_PROVIDERS, AIProvider, getProvider, CUSTOM_API_STORAGE_KEY } from '@/lib/aiProviders';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions'; import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import MatrixCursor from '@/components/MatrixCursor';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -406,6 +407,8 @@ const AIChat = () => {
: 'flex flex-col h-full' : 'flex flex-col h-full'
} }
> >
{/* Render cursor inside portal for fullscreen */}
{isFullscreen && <MatrixCursor />}
<div className="flex items-center justify-between gap-2 mb-3 flex-shrink-0"> <div className="flex items-center justify-between gap-2 mb-3 flex-shrink-0">
<GlitchText <GlitchText
text="AI Terminal" text="AI Terminal"
+22 -3
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext'; import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext'; import { useAchievements } from '@/contexts/AchievementsContext';
@@ -8,6 +9,7 @@ import { Maximize2, Minimize2 } from 'lucide-react';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions'; import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import GameTouchButton from '@/components/GameTouchButton'; import GameTouchButton from '@/components/GameTouchButton';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import MatrixCursor from '@/components/MatrixCursor';
const PADDLE_HEIGHT = 14; const PADDLE_HEIGHT = 14;
const BRICK_ROWS = 6; const BRICK_ROWS = 6;
@@ -132,6 +134,15 @@ const Breakout = () => {
if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); } if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); }
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]); }, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
// Prevent page scroll while in fullscreen mode (portal overlays the viewport)
useEffect(() => {
if (typeof document === 'undefined') return;
if (!isFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prevOverflow; };
}, [isFullscreen]);
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); } if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
else { setIsFullscreen(false); await exitFullscreen(); } else { setIsFullscreen(false); await exitFullscreen(); }
@@ -183,7 +194,8 @@ const Breakout = () => {
}, []); }, []);
const resetBall = useCallback((ballRefToReset: React.MutableRefObject<{x: number, y: number, dx: number, dy: number}>, paddleRefToReset: React.MutableRefObject<number>, lvl: number) => { const resetBall = useCallback((ballRefToReset: React.MutableRefObject<{x: number, y: number, dx: number, dy: number}>, paddleRefToReset: React.MutableRefObject<number>, lvl: number) => {
const speed = 2.5 + lvl * 0.3; // Slower base speed for classic Breakout feel (was 2.5)
const speed = 1.8 + lvl * 0.2;
ballRefToReset.current = { x: canvasSize.width / 2, y: canvasSize.height - 60, dx: (Math.random() > 0.5 ? 1 : -1) * speed, dy: -speed }; ballRefToReset.current = { x: canvasSize.width / 2, y: canvasSize.height - 60, dx: (Math.random() > 0.5 ? 1 : -1) * speed, dy: -speed };
paddleRefToReset.current = canvasSize.width / 2 - paddleWidth / 2; paddleRefToReset.current = canvasSize.width / 2 - paddleWidth / 2;
}, [canvasSize.width, canvasSize.height, paddleWidth]); }, [canvasSize.width, canvasSize.height, paddleWidth]);
@@ -538,9 +550,11 @@ const Breakout = () => {
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />; if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
return ( const gameUi = (
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} <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'}`}> className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-[100] bg-background p-4 w-screen h-screen' : 'h-full'}`}>
{/* Render cursor inside portal for fullscreen */}
{isFullscreen && <MatrixCursor />}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4"> <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> <Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
@@ -712,6 +726,11 @@ const Breakout = () => {
)} )}
</motion.div> </motion.div>
); );
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
}; };
export default Breakout; export default Breakout;
+78 -3
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext'; import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext'; import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,6 +10,7 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls } from '@/hooks/useSwipeControls'; import { useSwipeControls } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton'; import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const GRID_WIDTH = 21; const GRID_WIDTH = 21;
const GRID_HEIGHT = 21; const GRID_HEIGHT = 21;
@@ -90,6 +92,22 @@ const Pacman = () => {
const [showGlitchCrash, setShowGlitchCrash] = useState(false); const [showGlitchCrash, setShowGlitchCrash] = useState(false);
const [level, setLevel] = useState(1); const [level, setLevel] = useState(1);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
// Fruit bonus system (spawns at center after eating certain dot counts)
const [fruit, setFruit] = useState<{ x: number; y: number; type: string; points: number } | null>(null);
const [dotsEaten, setDotsEaten] = useState(0);
const fruitTimerRef = useRef<NodeJS.Timeout | null>(null);
const FRUIT_TYPES = [
{ type: 'cherry', points: 100, emoji: '🍒' },
{ type: 'strawberry', points: 300, emoji: '🍓' },
{ type: 'orange', points: 500, emoji: '🍊' },
{ type: 'apple', points: 700, emoji: '🍎' },
{ type: 'melon', points: 1000, emoji: '🍈' },
{ type: 'galaxian', points: 2000, emoji: '🚀' },
{ type: 'bell', points: 3000, emoji: '🔔' },
{ type: 'key', points: 5000, emoji: '🔑' },
];
const gameRef = useRef<HTMLDivElement>(null); const gameRef = useRef<HTMLDivElement>(null);
const powerTimerRef = useRef<NodeJS.Timeout | null>(null); const powerTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -137,6 +155,15 @@ const Pacman = () => {
if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); } if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); }
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]); }, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
// Prevent page scroll while in fullscreen mode (portal overlays the viewport)
useEffect(() => {
if (typeof document === 'undefined') return;
if (!isFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prevOverflow; };
}, [isFullscreen]);
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); } if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
else { setIsFullscreen(false); await exitFullscreen(); } else { setIsFullscreen(false); await exitFullscreen(); }
@@ -198,14 +225,27 @@ const Pacman = () => {
powerTimerRef.current = setTimeout(() => { setIsPowered(false); setGhosts(prev => prev.map(g => ({ ...g, eaten: false }))); }, POWER_DURATION); powerTimerRef.current = setTimeout(() => { setIsPowered(false); setGhosts(prev => prev.map(g => ({ ...g, eaten: false }))); }, POWER_DURATION);
}; };
// Spawn fruit at center of maze
const spawnFruit = useCallback(() => {
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
// Get fruit type based on level (capped at available types)
const fruitIndex = Math.min(level - 1, FRUIT_TYPES.length - 1);
const fruitType = FRUIT_TYPES[fruitIndex];
setFruit({ x: 10, y: 9, type: fruitType.emoji, points: fruitType.points });
// Fruit disappears after 10 seconds if not eaten
fruitTimerRef.current = setTimeout(() => setFruit(null), 10000);
}, [level, FRUIT_TYPES]);
const startGame = () => { const startGame = () => {
if (powerTimerRef.current) clearTimeout(powerTimerRef.current); if (powerTimerRef.current) clearTimeout(powerTimerRef.current);
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
setPacman({ x: 10, y: 15 }); setDirection('right'); setNextDirection('right'); setMouthOpen(true); setPacman({ x: 10, y: 15 }); setDirection('right'); setNextDirection('right'); setMouthOpen(true);
setPacman2({ x: 10, y: 15 }); setDirection2('right'); setNextDirection2('right'); setMouthOpen2(true); setPacman2({ x: 10, y: 15 }); setDirection2('right'); setNextDirection2('right'); setMouthOpen2(true);
setLives(3); setLives2(3); setLives(3); setLives2(3);
setGhosts([{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false }, { pos: { x: 10, y: 9 }, dir: 'up', eaten: false }, { pos: { x: 11, y: 9 }, dir: 'right', eaten: false }]); setGhosts([{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false }, { pos: { x: 10, y: 9 }, dir: 'up', eaten: false }, { pos: { x: 11, y: 9 }, dir: 'right', eaten: false }]);
const { dots: newDots, powerPellets: newPowerPellets } = initDots(); const { dots: newDots, powerPellets: newPowerPellets } = initDots();
setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1); setDots(newDots); setPowerPellets(newPowerPellets); setIsPowered(false); setScore(0); setScore2(0); setLevel(1);
setDotsEaten(0); setFruit(null);
setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false); setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); setShowModeSelection(false);
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false); setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
playSound('success'); gameRef.current?.focus(); playSound('success'); gameRef.current?.focus();
@@ -246,6 +286,7 @@ const Pacman = () => {
if (powerTimerRef.current) clearTimeout(powerTimerRef.current); if (powerTimerRef.current) clearTimeout(powerTimerRef.current);
if (invulnerableTimerRef.current) clearTimeout(invulnerableTimerRef.current); if (invulnerableTimerRef.current) clearTimeout(invulnerableTimerRef.current);
if (invulnerable2TimerRef.current) clearTimeout(invulnerable2TimerRef.current); if (invulnerable2TimerRef.current) clearTimeout(invulnerable2TimerRef.current);
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
}; }, []); }; }, []);
useEffect(() => { useEffect(() => {
@@ -350,6 +391,14 @@ const Pacman = () => {
playSound('success'); playSound('success');
} else if (dots.has(posKey)) { } else if (dots.has(posKey)) {
setDots(prev => { const nd = new Set(prev); nd.delete(posKey); return nd; }); setDots(prev => { const nd = new Set(prev); nd.delete(posKey); return nd; });
// Track dots eaten and spawn fruit at 70 and 170 dots
setDotsEaten(prev => {
const newCount = prev + 1;
if (newCount === 70 || newCount === 170) {
spawnFruit();
}
return newCount;
});
setPlayerScore(prev => { setPlayerScore(prev => {
const ns = Math.min(prev + 10, MAX_SCORE); const ns = Math.min(prev + 10, MAX_SCORE);
if (ns >= MAX_SCORE) setShowGlitchCrash(true); if (ns >= MAX_SCORE) setShowGlitchCrash(true);
@@ -358,6 +407,19 @@ const Pacman = () => {
}); });
playSound('hover'); playSound('hover');
} }
// Check for fruit pickup
if (fruit && newPos.x === fruit.x && newPos.y === fruit.y) {
setPlayerScore(prev => {
const ns = Math.min(prev + fruit.points, MAX_SCORE);
if (ns >= MAX_SCORE) setShowGlitchCrash(true);
if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); }
return ns;
});
setFruit(null);
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
playSound('success');
}
} }
} }
}; };
@@ -647,12 +709,18 @@ const Pacman = () => {
const ghostIndex = ghosts.findIndex(g => g.pos.x === x && g.pos.y === y && !g.eaten); const ghostIndex = ghosts.findIndex(g => g.pos.x === x && g.pos.y === y && !g.eaten);
const isDot = dots.has(`${x},${y}`); const isDot = dots.has(`${x},${y}`);
const isPowerPellet = powerPellets.has(`${x},${y}`); const isPowerPellet = powerPellets.has(`${x},${y}`);
const isFruitHere = fruit && fruit.x === x && fruit.y === y;
cells.push( cells.push(
<div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}> <div key={`${x}-${y}`} className={`flex items-center justify-center transition-all duration-200 ease-out ${isWall ? 'bg-primary/20 border border-primary/40' : isTunnel ? 'bg-background/30' : 'bg-background/50 border border-primary/5'}`} style={{ width: cellSize, height: cellSize }}>
{isPacmanHere && <div className={`drop-shadow-lg ${p1Invulnerable ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))', opacity: p1Invulnerable ? 0.5 : 1 }}>{renderPacman()}</div>} {isPacmanHere && <div className={`drop-shadow-lg ${p1Invulnerable ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))', opacity: p1Invulnerable ? 0.5 : 1 }}>{renderPacman()}</div>}
{isPacman2Here && <div className={`drop-shadow-lg ${p2Invulnerable ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))', opacity: p2Invulnerable ? 0.5 : 1 }}>{renderPacman2()}</div>} {isPacman2Here && <div className={`drop-shadow-lg ${p2Invulnerable ? 'animate-pulse' : ''}`} style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))', opacity: p2Invulnerable ? 0.5 : 1 }}>{renderPacman2()}</div>}
{ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>} {ghostIndex !== -1 && !isPacmanHere && !isPacman2Here && <div style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out' }}>{renderGhost(ghostIndex, ghosts[ghostIndex].eaten)}</div>}
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />} {isFruitHere && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && (
<div className="animate-bounce text-lg" style={{ fontSize: `${Math.max(cellSize * 0.7, 14)}px` }} title={`${fruit.points} points`}>
{fruit.type}
</div>
)}
{isDot && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && !isFruitHere && <div className="w-1.5 h-1.5 bg-primary/80 rounded-full transition-all duration-200 ease-out animate-pulse" style={{ animationDuration: '2s' }} />}
{isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />} {isPowerPellet && !isPacmanHere && !isPacman2Here && ghostIndex === -1 && <div className="w-3 h-3 bg-primary rounded-full animate-pulse box-glow transition-all duration-200 ease-out" />}
</div> </div>
); );
@@ -663,9 +731,11 @@ const Pacman = () => {
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />; if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
return ( const gameUi = (
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} <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'}`}> className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-[100] bg-background p-4 w-screen h-screen' : 'h-full'}`}>
{/* Render cursor inside portal for fullscreen */}
{isFullscreen && <MatrixCursor />}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4"> <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> <Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
@@ -892,6 +962,11 @@ const Pacman = () => {
)} )}
</motion.div> </motion.div>
); );
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
}; };
export default Pacman; export default Pacman;
+20 -2
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext'; import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext'; import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,6 +10,7 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls } from '@/hooks/useSwipeControls'; import { useSwipeControls } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton'; import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const GRID_SIZE = 20; const GRID_SIZE = 20;
const TICK_SPEED = 120; const TICK_SPEED = 120;
@@ -117,6 +119,15 @@ const Snake = () => {
} }
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]); }, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
// Prevent page scroll while in fullscreen mode (portal overlays the viewport)
useEffect(() => {
if (typeof document === 'undefined') return;
if (!isFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prevOverflow; };
}, [isFullscreen]);
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!isFullscreen) { if (!isFullscreen) {
setIsFullscreen(true); setIsFullscreen(true);
@@ -533,15 +544,17 @@ const Snake = () => {
); );
} }
return ( const gameUi = (
<motion.div <motion.div
ref={gameRef} ref={gameRef}
tabIndex={0} tabIndex={0}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`} className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-[100] bg-background p-4 w-screen h-screen' : 'h-full'}`}
> >
{/* Render cursor inside portal for fullscreen */}
{isFullscreen && <MatrixCursor />}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button> <button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
@@ -739,6 +752,11 @@ const Snake = () => {
)} )}
</motion.div> </motion.div>
); );
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
}; };
export default Snake; export default Snake;
+20 -2
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext'; import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext'; import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,6 +10,7 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls, SwipeDirection } from '@/hooks/useSwipeControls'; import { useSwipeControls, SwipeDirection } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton'; import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const BOARD_WIDTH = 10; const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20; const BOARD_HEIGHT = 20;
@@ -152,6 +154,15 @@ const Tetris = () => {
} }
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]); }, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
// Prevent page scroll while in fullscreen mode (portal overlays the viewport)
useEffect(() => {
if (typeof document === 'undefined') return;
if (!isFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prevOverflow; };
}, [isFullscreen]);
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!isFullscreen) { if (!isFullscreen) {
setIsFullscreen(true); setIsFullscreen(true);
@@ -530,9 +541,11 @@ const Tetris = () => {
); );
} }
return ( const gameUi = (
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} <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'}`}> className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-[100] bg-background p-4 w-screen h-screen' : 'h-full'}`}>
{/* Render cursor inside portal for fullscreen */}
{isFullscreen && <MatrixCursor />}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button> <button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
@@ -701,6 +714,11 @@ const Tetris = () => {
)} )}
</motion.div> </motion.div>
); );
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
}; };
export default Tetris; export default Tetris;