Compare commits

...

12 Commits

Author SHA1 Message Date
jory 4f2fd37e29 Default cursor is not being displayed on top of the custom one upon
exiting fullscreen
Added package-lock file so running code does not contain any
vulnerabilities anymore
Snake game also dynamically changes sized when fullscreen just like the
other games do
Invert glow effect colour on Snake as it had broken logic
2026-02-03 14:35:21 +01:00
jory c0c73f9e44 removed obsolete files and robots.txt, robots.txt should be handled by reverse proxy instead 2026-02-03 11:17:50 +01:00
gpt-engineer-app[bot] 35bb767c73 Improve fullscreen and swipe handling
- Lock mobile body scroll when swipe controls are enabled
- Enhance Tetris fullscreen sizing to fill viewport and adjust 2P label glow
- Invert 2P glow styling for Tetris 2P label with secondary color glow

X-Lovable-Edit-ID: edt-c94fddac-d6de-42a5-b52b-a7c31abb2519
2026-02-03 10:00:58 +00:00
gpt-engineer-app[bot] 95a1034667 Changes 2026-02-03 10:00:58 +00:00
gpt-engineer-app[bot] 30741d1490 Fix UI colors and fullscreen
- AIChat: switch non-user message bg to muted; ensure loading indicator uses muted bg
- Breakout: slow paddle/body speed for single/multiplayer; adjust fullscreen sizing logic
- Pacman/Snake/Tetromino color behavior: align P2 coloring to secondary theme; enlarge fullscreen game canvases
- Pacman 2P: invert Pacman2 color to secondary; adjust scoreboard visuals accordingly
- Breakout fullscreen canvases now fill screen correctly in 1P/2P modes

X-Lovable-Edit-ID: edt-81d11f66-89cd-4fdc-b76d-cca9bff1ac10
2026-02-03 09:38:19 +00:00
gpt-engineer-app[bot] dde1042223 Changes 2026-02-03 09:38:18 +00:00
gpt-engineer-app[bot] 51928560be 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
2026-02-02 08:49:30 +00:00
gpt-engineer-app[bot] 147b8cb43f Changes 2026-02-02 08:49:30 +00:00
gpt-engineer-app[bot] f4b2916693 Pacman add invulnerability
Add 3-second invulnerability for respawns in 2P Pac-Man, introduce per-player invulnerability state, timers, and visual blinking cues; fix 2P continue logic to handle post-loss respawns correctly. Also wire invulnerability into collision checks and render to reflect blinking.

X-Lovable-Edit-ID: edt-67c3618b-84a3-4349-8052-99157bfd2d9d
2026-01-23 00:00:46 +00:00
gpt-engineer-app[bot] d1c482f5c9 Changes 2026-01-23 00:00:46 +00:00
gpt-engineer-app[bot] bd1603a7b3 Fix UI fixes and inputs
- Adjusted MatrixCursor detection to rely on coarse pointer input
- Restored desktop Breakout mouse controls and added mouse move handler
- Fixed Breakout 2P board mouse support and added cursor interactions
- Reworked Tetris 2P to avoid old closure issues and updated next-piece placement visuals
- Cleaned up Games page to remove 2P logos/badges
- Reworked Breakout P1/P2 rendering layout for 2P optionality and updated boards
- Updated 2P board rendering to place next pieces adjacent to players for Tetris/Snake Breakout interactions

X-Lovable-Edit-ID: edt-feec3603-55f5-4501-8f73-f89563fc0668
2026-01-22 23:52:03 +00:00
gpt-engineer-app[bot] 07a2fe0c17 Changes 2026-01-22 23:52:02 +00:00
15 changed files with 369 additions and 603 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+18 -18
View File
@@ -2342,9 +2342,9 @@
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -6042,9 +6042,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.castarray": {
@@ -6977,9 +6977,9 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -7157,12 +7157,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0"
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -7172,13 +7172,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
-14
View File
@@ -1,14 +0,0 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
+10 -9
View File
@@ -7,18 +7,13 @@ const MatrixCursor = () => {
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
// Detect touch device - check for touch capability and if primary input is touch
// Detect touch device - only consider it a touch device if primary pointer is coarse (touch)
const checkTouchDevice = () => {
const hasTouchScreen = 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
// @ts-ignore - msMaxTouchPoints is IE-specific
navigator.msMaxTouchPoints > 0;
// Also check if the device has a fine pointer (mouse)
// Check if the device has a fine pointer (mouse) as primary input
const hasFinePrimary = window.matchMedia('(pointer: fine)').matches;
// Consider it a touch device if it has touch AND doesn't have fine pointer as primary
setIsTouchDevice(hasTouchScreen && !hasFinePrimary);
// Consider it a touch device only if it doesn't have a fine pointer as primary
setIsTouchDevice(!hasFinePrimary);
};
checkTouchDevice();
@@ -79,7 +74,13 @@ const MatrixCursor = () => {
document.removeEventListener('mouseenter', handleMouseEnter);
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('touchstart', handleTouch);
// Only remove cursor class if no other MatrixCursor components are mounted
// This prevents the cursor from showing when exiting fullscreen
const otherCursors = document.querySelectorAll('.matrix-cursor-crosshair');
if (otherCursors.length === 0) {
document.body.classList.remove('matrix-cursor');
}
};
}, [isTouchDevice]);
+5 -1
View File
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Settings, Monitor, Volume2, Cpu, X, Contrast, Binary, Smartphone } from 'lucide-react';
import { useSettings, MobileControlMode } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
import { useIsMobile } from '@/hooks/use-mobile';
import CryptoConsentModal from './CryptoConsentModal';
interface SettingsPanelProps {
@@ -15,6 +16,7 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => {
const [showCryptoModal, setShowCryptoModal] = useState(false);
const { crtEnabled, setCrtEnabled, matrixEnabled, setMatrixEnabled, soundEnabled, setSoundEnabled, cryptoConsent, playSound, mobileControlMode, setMobileControlMode } = useSettings();
const { unlockAchievement } = useAchievements();
const isMobile = useIsMobile();
const handleToggle = (setter: (value: boolean) => void, currentValue: boolean, achievementId?: string) => {
playSound('click');
@@ -156,10 +158,11 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => {
</div>
{/* Mobile Controls Mode */}
{isMobile && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone size={16} className="text-primary" />
<span className="font-pixel text-sm text-foreground/90">Mobile Controls</span>
<span className="font-pixel text-sm text-foreground/90">Game Controls (for touch devices)</span>
</div>
<button
onClick={handleMobileControlToggle}
@@ -168,6 +171,7 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => {
{mobileControlMode === 'buttons' ? 'BUTTONS' : 'SWIPE'}
</button>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-primary/30">
+9
View File
@@ -41,6 +41,12 @@ export const useSwipeControls = ({
useEffect(() => {
if (!enabled) return;
// Lock body scroll when swipe controls are enabled
const prevOverflow = document.body.style.overflow;
const prevTouchAction = document.body.style.touchAction;
document.body.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
const handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0];
swipeStateRef.current = {
@@ -99,6 +105,9 @@ export const useSwipeControls = ({
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchCancel);
// Restore body scroll
document.body.style.overflow = prevOverflow;
document.body.style.touchAction = prevTouchAction;
};
}, [enabled, minSwipeDistance, fastSwipeThreshold]);
+4 -4
View File
@@ -40,8 +40,8 @@ html {
--primary: 120 100% 50%;
--primary-foreground: 0 0% 0%;
--secondary: 120 100% 15%;
--secondary-foreground: 120 100% 50%;
--secondary: 0 100% 50%;
--secondary-foreground: 0 0% 0%;
--muted: 120 20% 10%;
--muted-foreground: 120 30% 60%;
@@ -82,8 +82,8 @@ html {
--primary: 0 100% 50%;
--primary-foreground: 0 0% 0%;
--secondary: 0 100% 15%;
--secondary-foreground: 0 100% 50%;
--secondary: 120 100% 50%;
--secondary-foreground: 0 0% 0%;
--muted: 0 20% 10%;
--muted-foreground: 0 30% 60%;
+5 -2
View File
@@ -12,6 +12,7 @@ import GlitchText from '@/components/GlitchText';
import MessageContent from '@/components/MessageContent';
import { AI_PROVIDERS, AIProvider, getProvider, CUSTOM_API_STORAGE_KEY } from '@/lib/aiProviders';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import MatrixCursor from '@/components/MatrixCursor';
import {
DropdownMenu,
DropdownMenuContent,
@@ -406,6 +407,8 @@ const AIChat = () => {
: '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">
<GlitchText
text="AI Terminal"
@@ -575,7 +578,7 @@ const AIChat = () => {
className={`max-w-[90%] sm:max-w-[85%] md:max-w-[80%] p-2 sm:p-3 rounded-lg text-xs sm:text-sm overflow-x-auto ${
message.role === 'user'
? 'bg-primary/20 border border-primary/50'
: 'bg-secondary/50 border border-primary/30'
: 'bg-muted border border-primary/30'
}`}
>
<MessageContent
@@ -604,7 +607,7 @@ const AIChat = () => {
<div className="w-8 h-8 rounded border border-primary/50 flex items-center justify-center bg-primary/10">
<Bot className="w-4 h-4 text-primary" />
</div>
<div className="p-3 rounded-lg bg-secondary/50 border border-primary/30">
<div className="p-3 rounded-lg bg-muted border border-primary/30">
<Loader2 className="w-4 h-4 text-primary animate-spin" />
</div>
</motion.div>
+52 -7
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
@@ -8,6 +9,7 @@ import { Maximize2, Minimize2 } from 'lucide-react';
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import GameTouchButton from '@/components/GameTouchButton';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import MatrixCursor from '@/components/MatrixCursor';
const PADDLE_HEIGHT = 14;
const BRICK_ROWS = 6;
@@ -104,10 +106,26 @@ const Breakout = () => {
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
return { width: Math.floor(width), height: Math.floor(height) };
}
if (isFullscreen) {
const aspectRatio = 560 / 680;
if (isTwoPlayer) {
return isFullscreen ? { width: 480, height: 580 } : { width: 400, height: 500 };
// Two canvases side by side
const maxWidth = (window.innerWidth - 100) / 2;
const maxHeight = window.innerHeight - 120;
let width = maxWidth;
let height = width / aspectRatio;
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
return { width: Math.floor(width), height: Math.floor(height) };
}
return isFullscreen ? { width: 700, height: 840 } : { width: 560, height: 680 };
// Single player - fill screen
const maxWidth = window.innerWidth - 280;
const maxHeight = window.innerHeight - 100;
let width = maxWidth;
let height = width / aspectRatio;
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
return { width: Math.floor(width), height: Math.floor(height) };
}
return isTwoPlayer ? { width: 400, height: 500 } : { width: 560, height: 680 };
}, [isFullscreen, isTwoPlayer]);
const [canvasSize, setCanvasSize] = useState(getCanvasSize);
@@ -132,6 +150,15 @@ const Breakout = () => {
if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); 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 () => {
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
else { setIsFullscreen(false); await exitFullscreen(); }
@@ -183,7 +210,8 @@ const Breakout = () => {
}, []);
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 };
paddleRefToReset.current = canvasSize.width / 2 - paddleWidth / 2;
}, [canvasSize.width, canvasSize.height, paddleWidth]);
@@ -261,6 +289,16 @@ const Breakout = () => {
paddleXRef.current = Math.max(0, Math.min(canvasSize.width - paddleWidth, x - paddleWidth / 2));
}, [gameStarted, gameOver, isPaused, canvasSize.width, paddleWidth]);
// Mouse move handler for desktop
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!gameStarted || gameOver || isPaused || isMobile || isTwoPlayer) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
paddleXRef.current = Math.max(0, Math.min(canvasSize.width - paddleWidth, x - paddleWidth / 2));
}, [gameStarted, gameOver, isPaused, canvasSize.width, paddleWidth, isMobile, isTwoPlayer]);
const moveLeft = useCallback(() => { paddleXRef.current = Math.max(0, paddleXRef.current - 20); }, []);
const moveRight = useCallback(() => { paddleXRef.current = Math.min(canvasSize.width - paddleWidth, paddleXRef.current + 20); }, [canvasSize.width, paddleWidth]);
@@ -282,7 +320,7 @@ const Breakout = () => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const paddleSpeed = isMobile ? 6 : (isFullscreen ? 10 : 8);
const paddleSpeed = isMobile ? 4 : (isFullscreen ? 6 : 5);
if (keysRef.current.has(leftKey)) paddleRefParam.current = Math.max(0, paddleRefParam.current - paddleSpeed);
if (keysRef.current.has(rightKey)) paddleRefParam.current = Math.min(canvasSize.width - paddleWidth, paddleRefParam.current + paddleSpeed);
@@ -528,9 +566,11 @@ const Breakout = () => {
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 }}
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 gap-4">
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
@@ -547,7 +587,7 @@ const Breakout = () => {
<div className={`flex ${isTwoPlayer ? 'gap-4' : ''}`}>
{/* P1 Board */}
<div className="border-2 border-primary box-glow bg-background/80">
<canvas ref={canvasRef} width={canvasSize.width} height={canvasSize.height} onTouchMove={handleTouchMove} className="block" />
<canvas ref={canvasRef} width={canvasSize.width} height={canvasSize.height} onTouchMove={handleTouchMove} onMouseMove={handleMouseMove} className="block cursor-none" />
</div>
{/* P2 Board */}
@@ -702,6 +742,11 @@ const Breakout = () => {
)}
</motion.div>
);
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
};
export default Breakout;
-10
View File
@@ -8,7 +8,6 @@ const games = [
id: 'tetris',
name: 'Tetris',
description: 'Classic block-stacking puzzle game',
hasMultiplayer: true,
ascii: `┌────────┐
│ ▓▓ │
│ ▓▓ ██ │
@@ -20,7 +19,6 @@ const games = [
id: 'pacman',
name: 'Pac-Man',
description: 'Navigate the maze, eat dots, avoid ghosts',
hasMultiplayer: true,
ascii: `┌────────┐
│· · ᗣ · │
│ ┌─┐ ┌─┐│
@@ -32,7 +30,6 @@ const games = [
id: 'snake',
name: 'Snake',
description: 'Eat food, grow longer, dont hit yourself',
hasMultiplayer: true,
ascii: `┌────────┐
│ │
│ ●■■■ │
@@ -44,7 +41,6 @@ const games = [
id: 'breakout',
name: 'Breakout',
description: 'Break bricks with a bouncing ball',
hasMultiplayer: true,
ascii: `┌────────┐
│████████│
│▓▓▓▓▓▓▓▓│
@@ -110,12 +106,6 @@ const Games = () => {
</p>
</div>
{/* Multiplayer Badge - using primary colors instead of purple */}
{game.hasMultiplayer && (
<span className="absolute top-3 right-3 font-pixel text-[10px] text-primary border border-primary/60 bg-primary/10 px-2 py-1 rounded-sm">
2P
</span>
)}
</div>
</Link>
</motion.div>
+144 -18
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,6 +10,7 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const GRID_WIDTH = 21;
const GRID_HEIGHT = 21;
@@ -68,6 +70,10 @@ const Pacman = () => {
const [player1Eliminated, setPlayer1Eliminated] = useState(false);
const [player2Eliminated, setPlayer2Eliminated] = useState(false);
const [showEliminationPrompt, setShowEliminationPrompt] = useState(false);
const [p1Invulnerable, setP1Invulnerable] = useState(false);
const [p2Invulnerable, setP2Invulnerable] = useState(false);
const invulnerableTimerRef = useRef<NodeJS.Timeout | null>(null);
const invulnerable2TimerRef = useRef<NodeJS.Timeout | null>(null);
const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
@@ -86,6 +92,22 @@ const Pacman = () => {
const [showGlitchCrash, setShowGlitchCrash] = useState(false);
const [level, setLevel] = useState(1);
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 powerTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -100,7 +122,13 @@ const Pacman = () => {
const maxHeight = window.innerHeight - 420;
return Math.min(Math.floor(maxWidth / GRID_WIDTH), Math.floor(maxHeight / GRID_HEIGHT), 18);
}
return isFullscreen ? 30 : 24;
if (isFullscreen) {
// Fill screen more in fullscreen - calculate based on available space
const maxWidth = window.innerWidth - 240; // Space for side panel
const maxHeight = window.innerHeight - 100; // Space for header
return Math.min(Math.floor(maxWidth / GRID_WIDTH), Math.floor(maxHeight / GRID_HEIGHT));
}
return 24;
}, [isFullscreen]);
const [cellSize, setCellSize] = useState(getCellSize);
@@ -133,6 +161,15 @@ const Pacman = () => {
if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); 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 () => {
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
else { setIsFullscreen(false); await exitFullscreen(); }
@@ -194,14 +231,27 @@ const Pacman = () => {
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 = () => {
if (powerTimerRef.current) clearTimeout(powerTimerRef.current);
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
setPacman({ x: 10, y: 15 }); setDirection('right'); setNextDirection('right'); setMouthOpen(true);
setPacman2({ x: 10, y: 15 }); setDirection2('right'); setNextDirection2('right'); setMouthOpen2(true);
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 }]);
const { dots: newDots, powerPellets: newPowerPellets } = initDots();
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);
setPlayer1Eliminated(false); setPlayer2Eliminated(false); setShowEliminationPrompt(false);
playSound('success'); gameRef.current?.focus();
@@ -211,10 +261,39 @@ const Pacman = () => {
setGameMode('1p');
setShowEliminationPrompt(false);
setIsPaused(false);
// If P1 was eliminated, remaining player continues as P1
if (player1Eliminated && !player2Eliminated) {
// P2 becomes P1
setPacman(pacman2);
setDirection(direction2);
setNextDirection(nextDirection2);
setScore(score2);
setLives(lives2);
setPlayer1Eliminated(false);
setPacman2({ x: -1, y: -1 });
}
// If P2 was eliminated, P1 just continues normally
playSound('success');
};
useEffect(() => { return () => { if (powerTimerRef.current) clearTimeout(powerTimerRef.current); }; }, []);
const makeP1Invulnerable = () => {
if (invulnerableTimerRef.current) clearTimeout(invulnerableTimerRef.current);
setP1Invulnerable(true);
invulnerableTimerRef.current = setTimeout(() => setP1Invulnerable(false), 3000);
};
const makeP2Invulnerable = () => {
if (invulnerable2TimerRef.current) clearTimeout(invulnerable2TimerRef.current);
setP2Invulnerable(true);
invulnerable2TimerRef.current = setTimeout(() => setP2Invulnerable(false), 3000);
};
useEffect(() => { return () => {
if (powerTimerRef.current) clearTimeout(powerTimerRef.current);
if (invulnerableTimerRef.current) clearTimeout(invulnerableTimerRef.current);
if (invulnerable2TimerRef.current) clearTimeout(invulnerable2TimerRef.current);
if (fruitTimerRef.current) clearTimeout(fruitTimerRef.current);
}; }, []);
useEffect(() => {
if (!gameStarted || gameOver || gameComplete || isPaused) return;
@@ -260,8 +339,12 @@ const Pacman = () => {
});
playSound('success');
} else if (!ghostAtTarget.eaten) {
// Check invulnerability
const isPlayer1 = setPlayer === setPacman;
const isInvulnerable = isPlayer1 ? p1Invulnerable : p2Invulnerable;
if (!isInvulnerable) {
// Hit by ghost - lose life without moving
const isPlayer1 = player === pacman;
const currentLives = isPlayer1 ? lives : lives2;
const setLivesFunc = isPlayer1 ? setLives : setLives2;
@@ -269,8 +352,10 @@ const Pacman = () => {
setLivesFunc(newLives);
if (newLives > 0) {
// Respawn at start
// Respawn at start with invulnerability
setPlayer({ x: 10, y: 15 });
if (isPlayer1) makeP1Invulnerable();
else makeP2Invulnerable();
playSound('error');
} else {
// Player eliminated
@@ -290,6 +375,10 @@ const Pacman = () => {
setGameOver(true);
}
}
} else {
// Invulnerable - just move through
setPlayer(newPos);
}
}
} else {
// No ghost at target, safe to move
@@ -308,6 +397,14 @@ const Pacman = () => {
playSound('success');
} else if (dots.has(posKey)) {
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 => {
const ns = Math.min(prev + 10, MAX_SCORE);
if (ns >= MAX_SCORE) setShowGlitchCrash(true);
@@ -316,6 +413,19 @@ const Pacman = () => {
});
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');
}
}
}
};
@@ -337,12 +447,13 @@ const Pacman = () => {
return ns;
});
playSound('success');
} else if (!ghost.eaten) {
} else if (!ghost.eaten && !p1Invulnerable) {
// Player 1 dies
const newLives = lives - 1;
setLives(newLives);
if (newLives > 0) {
setPacman({ x: 10, y: 15 });
makeP1Invulnerable();
playSound('error');
} else {
setPlayer1Eliminated(true);
@@ -402,12 +513,13 @@ const Pacman = () => {
});
playSound('success');
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g);
} else if (!ghost.eaten) {
} else if (!ghost.eaten && !p1Invulnerable) {
// Ghost kills player
const newLives = lives - 1;
setLives(newLives);
if (newLives > 0) {
setPacman({ x: 10, y: 15 });
makeP1Invulnerable();
playSound('error');
} else {
setPlayer1Eliminated(true);
@@ -443,12 +555,13 @@ const Pacman = () => {
});
playSound('success');
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g);
} else if (!ghost.eaten) {
} else if (!ghost.eaten && !p2Invulnerable) {
// Ghost kills player
const newLives = lives2 - 1;
setLives2(newLives);
if (newLives > 0) {
setPacman2({ x: 10, y: 15 });
makeP2Invulnerable();
playSound('error');
} else {
setPlayer2Eliminated(true);
@@ -469,7 +582,7 @@ const Pacman = () => {
}
}, TICK_SPEED);
return () => clearInterval(interval);
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated]);
}, [gameStarted, gameOver, gameComplete, isPaused, pacman, direction, nextDirection, pacman2, direction2, nextDirection2, dots, powerPellets, highScore, isPowered, playSound, gameMode, player1Eliminated, player2Eliminated, p1Invulnerable, p2Invulnerable, lives, lives2]);
@@ -571,7 +684,7 @@ const Pacman = () => {
const renderPacman2 = () => (
<svg viewBox="0 0 100 100" className="w-full h-full transition-transform duration-100 ease-out" style={{ transform: `rotate(${getPacmanRotation2()}deg)` }}>
<circle cx="50" cy="50" r="45" fill="hsl(120 70% 50%)" className="transition-all duration-100 ease-out" />
<circle cx="50" cy="50" r="45" fill="hsl(var(--secondary))" className="transition-all duration-100 ease-out" />
{mouthOpen2 && <path d="M 50 50 L 95 25 L 95 75 Z" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />}
<circle cx="50" cy="25" r="6" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />
</svg>
@@ -602,12 +715,18 @@ const Pacman = () => {
const ghostIndex = ghosts.findIndex(g => g.pos.x === x && g.pos.y === y && !g.eaten);
const isDot = dots.has(`${x},${y}`);
const isPowerPellet = powerPellets.has(`${x},${y}`);
const isFruitHere = fruit && fruit.x === x && fruit.y === y;
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 }}>
{isPacmanHere && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))' }}>{renderPacman()}</div>}
{isPacman2Here && <div className="drop-shadow-lg" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))' }}>{renderPacman2()}</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(var(--secondary)))', 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>}
{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" />}
</div>
);
@@ -618,9 +737,11 @@ const Pacman = () => {
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 }}
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 gap-4">
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
@@ -649,8 +770,8 @@ const Pacman = () => {
<p className="font-minecraft text-lg text-primary text-glow">{score.toLocaleString()}</p>
</div>
<div className="text-center">
<p className="font-pixel text-[8px]" style={{ color: 'hsl(120 70% 50%)' }}>P2</p>
<p className="font-minecraft text-lg" style={{ color: 'hsl(120 70% 50%)', textShadow: '0 0 10px hsl(120 70% 50%)' }}>{score2.toLocaleString()}</p>
<p className="font-pixel text-[8px] text-secondary">P2</p>
<p className="font-minecraft text-lg text-secondary" style={{ textShadow: '0 0 10px hsl(var(--secondary))' }}>{score2.toLocaleString()}</p>
</div>
</div>
</div>
@@ -680,7 +801,7 @@ const Pacman = () => {
<div className="flex justify-center gap-2 mt-1">
<span className="font-minecraft text-sm text-primary">{lives}</span>
<span className="text-foreground/30">/</span>
<span className="font-minecraft text-sm" style={{ color: 'hsl(120 70% 50%)' }}>{lives2}</span>
<span className="font-minecraft text-sm text-secondary">{lives2}</span>
</div>
</div>
) : (
@@ -701,7 +822,7 @@ const Pacman = () => {
<p className="font-pixel text-[9px] text-foreground/60">WASD</p>
</div>
<div>
<p className="font-pixel text-[8px]" style={{ color: 'hsl(120 70% 50%)' }}>P2</p>
<p className="font-pixel text-[8px] text-secondary">P2</p>
<p className="font-pixel text-[9px] text-foreground/60">IJKL</p>
</div>
</div>
@@ -847,6 +968,11 @@ const Pacman = () => {
)}
</motion.div>
);
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
};
export default Pacman;
+33 -6
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,9 +10,10 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const GRID_SIZE = 20;
const TICK_SPEED = 120;
const TICK_SPEED = 150;
const MAX_SCORE = 4294967296;
const HIGHSCORE_KEY = 'snake-highscore';
@@ -76,10 +78,19 @@ const Snake = () => {
const maxHeight = window.innerHeight - 420;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 20);
}
if (isFullscreen) {
if (gameMode === '2p') {
return isFullscreen ? 24 : 20;
// Two grids side by side
const maxWidth = (window.innerWidth - 100) / 2;
const maxHeight = window.innerHeight - 150;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE));
}
return isFullscreen ? 32 : 28;
// Single player - fill screen
const maxWidth = window.innerWidth - 280;
const maxHeight = window.innerHeight - 100;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE));
}
return gameMode === '2p' ? 20 : 28;
}, [isFullscreen, gameMode]);
const [cellSize, setCellSize] = useState(getCellSize);
@@ -117,6 +128,15 @@ const Snake = () => {
}
}, [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 () => {
if (!isFullscreen) {
setIsFullscreen(true);
@@ -533,20 +553,22 @@ const Snake = () => {
);
}
return (
const gameUi = (
<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 gap-4">
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
Snake {gameMode === '2p' && <span className="text-secondary">2P</span>}
Snake {gameMode === '2p' && <span className="text-secondary" style={{ textShadow: '0 0 10px hsl(var(--secondary) / 0.8), 0 0 20px hsl(var(--secondary) / 0.4)' }}>2P</span>}
</h1>
</div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
@@ -739,6 +761,11 @@ const Snake = () => {
)}
</motion.div>
);
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
};
export default Snake;
+51 -22
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'framer-motion';
import { useSettings } from '@/contexts/SettingsContext';
import { useAchievements } from '@/contexts/AchievementsContext';
@@ -9,6 +10,7 @@ import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
import { useSwipeControls, SwipeDirection } from '@/hooks/useSwipeControls';
import GameTouchButton from '@/components/GameTouchButton';
import MatrixCursor from '@/components/MatrixCursor';
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20;
@@ -130,8 +132,16 @@ const Tetris = () => {
const maxHeight = window.innerHeight - 320;
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 20);
}
if (gameMode === '2p') return isFullscreen ? 26 : 22;
return isFullscreen ? 34 : 28;
if (isFullscreen) {
// Dynamic sizing based on viewport - same as other games
const availableWidth = window.innerWidth - (gameMode === '2p' ? 400 : 280);
const availableHeight = window.innerHeight - 120;
const cellFromWidth = Math.floor(availableWidth / (gameMode === '2p' ? BOARD_WIDTH * 2 + 4 : BOARD_WIDTH));
const cellFromHeight = Math.floor(availableHeight / BOARD_HEIGHT);
return Math.min(cellFromWidth, cellFromHeight);
}
if (gameMode === '2p') return 22;
return 28;
}, [isFullscreen, gameMode]);
const [cellSize, setCellSize] = useState(getCellSize);
@@ -152,6 +162,15 @@ const Tetris = () => {
}
}, [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 () => {
if (!isFullscreen) {
setIsFullscreen(true);
@@ -339,31 +358,26 @@ const Tetris = () => {
const movePlayer = useCallback((playerNum: 1 | 2, action: 'left' | 'right' | 'down' | 'rotate' | 'drop') => {
if (gameOver || isPaused || !gameStarted || gameMode !== '2p') return;
const setPlayer = playerNum === 1 ? setPlayer1 : setPlayer2;
const player = playerNum === 1 ? player1 : player2;
const isP2 = playerNum === 2;
if (player.gameOver) return;
setPlayer(prev => {
if (prev.gameOver) return prev;
let newPiece = { ...prev.piece };
switch (action) {
case 'left':
newPiece.x--;
if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece };
case 'right':
newPiece.x++;
if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece };
case 'rotate':
newPiece.shape = rotate(prev.piece.shape);
if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece };
case 'drop':
while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, prev.board)) newPiece.y++;
playSound('click');
return { ...prev, piece: newPiece };
case 'down':
newPiece.y++;
@@ -373,12 +387,9 @@ const Tetris = () => {
const { board: clearedBoard, cleared } = clearLines(mergedBoard);
const newScore = prev.score + (cleared > 0 ? cleared * 100 * cleared : 0);
const newLines = prev.lines + cleared;
if (cleared > 0) playSound('success');
else playSound('click');
const newTetromino = prev.nextPiece;
const nextNext = randomTetromino(isP2, prev.nextPiece.shape);
if (!isValidMove(newTetromino, clearedBoard)) {
playSound('error');
return { ...prev, board: clearedBoard, score: newScore, lines: newLines, gameOver: true };
}
return { ...prev, board: clearedBoard, piece: newTetromino, nextPiece: nextNext, score: newScore, lines: newLines };
@@ -386,7 +397,7 @@ const Tetris = () => {
}
return prev;
});
}, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]);
}, [gameOver, isPaused, gameStarted, gameMode, isValidMove, mergePiece, clearLines]);
useEffect(() => {
if (gameMode !== '2p' || !gameStarted) return;
@@ -497,7 +508,7 @@ const Tetris = () => {
{nextP.shape.map((row, y) => (
<div key={y} className="flex gap-1">
{row.map((val, x) => (
<div key={`${y}-${x}`} className={`w-4 h-4`} style={{ backgroundColor: val ? nextP.color : 'transparent', boxShadow: val ? `0 0 5px ${nextP.color}` : 'none' }} />
<div key={`${y}-${x}`} style={{ width: cellSize * 0.6, height: cellSize * 0.6, backgroundColor: val ? nextP.color : 'transparent', boxShadow: val ? `0 0 5px ${nextP.color}` : 'none' }} />
))}
</div>
))}
@@ -538,14 +549,16 @@ const Tetris = () => {
);
}
return (
const gameUi = (
<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 gap-4">
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
Tetris {gameMode === '2p' && <span className="text-secondary">2P</span>}
Tetris {gameMode === '2p' && <span className="text-secondary" style={{ textShadow: '0 0 10px hsl(var(--secondary) / 0.8), 0 0 20px hsl(var(--secondary) / 0.4)' }}>2P</span>}
</h1>
</div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
@@ -599,7 +612,16 @@ const Tetris = () => {
)}
</>
) : (
<div className="flex gap-4 items-start">
<div className="flex gap-6 items-start">
{/* P1 Next Piece - Left side */}
<div className="flex flex-col items-center gap-2 min-w-[60px]">
<p className="font-pixel text-[10px] text-primary/70">P1 Next</p>
<div className="border border-primary/30 p-2 bg-background/40">
{renderNextPiece(player1.nextPiece)}
</div>
</div>
{/* P1 Board */}
<div className="flex flex-col items-center gap-2">
<p className="font-minecraft text-lg text-primary">P1 <span className="text-xs text-foreground/60">(WASD + Q)</span></p>
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
@@ -607,11 +629,9 @@ const Tetris = () => {
<div><p className="font-pixel text-[10px] text-foreground/60">Score</p><p className="font-minecraft text-lg text-primary">{player1.score}</p></div>
<div><p className="font-pixel text-[10px] text-foreground/60">Lines</p><p className="font-minecraft text-lg text-primary">{player1.lines}</p></div>
</div>
<div className="border border-primary/30 p-2 bg-background/40">
<p className="font-pixel text-[8px] text-foreground/40">Next</p>
{renderNextPiece(player1.nextPiece)}
</div>
</div>
{/* P2 Board */}
<div className="flex flex-col items-center gap-2">
<p className="font-minecraft text-lg text-secondary">P2 <span className="text-xs text-foreground/60">(Arrows + /)</span></p>
<div className="border-2 border-secondary p-1 bg-background/80" style={{ boxShadow: '0 0 15px hsl(var(--secondary) / 0.5)' }}>{renderBoard2P(player2, 'border-secondary/20')}</div>
@@ -619,8 +639,12 @@ const Tetris = () => {
<div><p className="font-pixel text-[10px] text-foreground/60">Score</p><p className="font-minecraft text-lg text-secondary">{player2.score}</p></div>
<div><p className="font-pixel text-[10px] text-foreground/60">Lines</p><p className="font-minecraft text-lg text-secondary">{player2.lines}</p></div>
</div>
</div>
{/* P2 Next Piece - Right side */}
<div className="flex flex-col items-center gap-2 min-w-[60px]">
<p className="font-pixel text-[10px] text-secondary/70">P2 Next</p>
<div className="border border-secondary/30 p-2 bg-background/40">
<p className="font-pixel text-[8px] text-foreground/40">Next</p>
{renderNextPiece(player2.nextPiece)}
</div>
</div>
@@ -698,6 +722,11 @@ const Tetris = () => {
)}
</motion.div>
);
// Portal to body for true fullscreen, escaping MainLayout transforms
return isFullscreen && typeof document !== 'undefined'
? createPortal(gameUi, document.body)
: gameUi;
};
export default Tetris;
-454
View File
@@ -1,454 +0,0 @@
/**
* Video Export Test API
*
* Exposes a global API for automated testing of video exports.
*
* Usage in browser console or automated tests:
*
* // Run export with a test audio file
* const result = await window.VideoExportTestAPI.runExport(audioFileBlob, {
* width: 1920,
* height: 1080,
* fps: 60,
* mode: 'combined'
* });
*
* // result = { success: boolean, url?: string, error?: string, stats: {...} }
*
* // Download the result
* window.VideoExportTestAPI.downloadBlob(result.url, 'test-output.webm');
*
* // Validate the blob (basic checks)
* const validation = await window.VideoExportTestAPI.validateBlob(result.url);
* // validation = { valid: boolean, size: number, type: string, issues: string[] }
*/
import type { OscilloscopeMode } from '../hooks/useOscilloscopeRenderer';
export interface TestExportOptions {
width?: number;
height?: number;
fps?: number;
mode?: OscilloscopeMode;
}
export interface TestExportResult {
success: boolean;
url?: string;
error?: string;
stats: {
duration: number;
blobSize: number;
mimeType: string;
exportTimeMs: number;
};
}
export interface ValidationResult {
valid: boolean;
size: number;
type: string;
issues: string[];
}
// Simple audio analyzer for test purposes
async function analyzeAudio(file: File): Promise<{
leftChannel: Float32Array;
rightChannel: Float32Array;
sampleRate: number;
}> {
const audioContext = new AudioContext();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.numberOfChannels > 1
? audioBuffer.getChannelData(1)
: leftChannel;
await audioContext.close();
return {
leftChannel,
rightChannel,
sampleRate: audioBuffer.sampleRate,
};
}
class VideoExportTestAPIClass {
async runExport(
audioFile: File | Blob,
options: TestExportOptions = {}
): Promise<TestExportResult> {
const startTime = performance.now();
const file = audioFile instanceof File
? audioFile
: new File([audioFile], 'test-audio.mp3', { type: audioFile.type });
const opts = {
width: options.width ?? 1920,
height: options.height ?? 1080,
fps: options.fps ?? 60,
mode: options.mode ?? 'combined' as OscilloscopeMode,
};
console.log('[VideoExportTestAPI] Starting export with options:', opts);
try {
// Analyze audio
const audioData = await analyzeAudio(file);
console.log('[VideoExportTestAPI] Audio analyzed:', {
sampleRate: audioData.sampleRate,
duration: audioData.leftChannel.length / audioData.sampleRate,
samples: audioData.leftChannel.length,
});
// Execute export
const url = await this.executeExport(audioData, file, opts);
const blob = await fetch(url).then(r => r.blob());
const exportTimeMs = performance.now() - startTime;
const result: TestExportResult = {
success: true,
url,
stats: {
duration: audioData.leftChannel.length / audioData.sampleRate,
blobSize: blob.size,
mimeType: blob.type,
exportTimeMs,
},
};
console.log('[VideoExportTestAPI] Export completed:', result);
return result;
} catch (error) {
const exportTimeMs = performance.now() - startTime;
const result: TestExportResult = {
success: false,
error: error instanceof Error ? error.message : String(error),
stats: {
duration: 0,
blobSize: 0,
mimeType: '',
exportTimeMs,
},
};
console.error('[VideoExportTestAPI] Export failed:', result);
return result;
}
}
private async executeExport(
audioData: { leftChannel: Float32Array; rightChannel: Float32Array; sampleRate: number },
audioFile: File,
options: { width: number; height: number; fps: number; mode: OscilloscopeMode }
): Promise<string> {
const { width, height, fps, mode } = options;
const totalSamples = audioData.leftChannel.length;
const samplesPerFrame = Math.floor(audioData.sampleRate / fps);
const log = (...args: unknown[]) => {
console.log('[VideoExportTestAPI]', ...args);
};
// Create canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not get 2D context');
const leftColor = '#00ff00';
const rightColor = '#00ccff';
const xyColor = '#ff8800';
const dividerColor = '#333333';
const renderFrame = (startSample: number) => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 2;
const endSample = Math.min(startSample + samplesPerFrame, totalSamples);
if (mode === 'combined') {
ctx.strokeStyle = leftColor;
ctx.beginPath();
const samplesPerPixel = samplesPerFrame / width;
const centerY = height / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2;
const y = centerY - sample * (height * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else if (mode === 'separate') {
const halfHeight = height / 2;
const samplesPerPixel = samplesPerFrame / width;
// Left (top)
ctx.strokeStyle = leftColor;
ctx.beginPath();
const leftCenterY = halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (halfHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Right (bottom)
ctx.strokeStyle = rightColor;
ctx.beginPath();
const rightCenterY = halfHeight + halfHeight / 2;
for (let x = 0; x < width; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (halfHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Divider
ctx.strokeStyle = dividerColor;
ctx.beginPath();
ctx.moveTo(0, halfHeight);
ctx.lineTo(width, halfHeight);
ctx.stroke();
} else if (mode === 'all') {
const topHeight = height / 2;
const bottomHeight = height / 2;
const halfWidth = width / 2;
const samplesPerPixel = samplesPerFrame / halfWidth;
// Left (top-left)
ctx.strokeStyle = leftColor;
ctx.beginPath();
const leftCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (topHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Right (top-right)
ctx.strokeStyle = rightColor;
ctx.beginPath();
const rightCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= totalSamples) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (topHeight * 0.35);
if (x === 0) ctx.moveTo(halfWidth + x, y);
else ctx.lineTo(halfWidth + x, y);
}
ctx.stroke();
// XY (bottom half)
ctx.strokeStyle = xyColor;
ctx.beginPath();
const xyCenterX = width / 2;
const xyCenterY = topHeight + bottomHeight / 2;
const xyScale = Math.min(halfWidth, bottomHeight) * 0.35;
for (let i = startSample; i < endSample; i++) {
const x = xyCenterX + audioData.leftChannel[i] * xyScale;
const y = xyCenterY - audioData.rightChannel[i] * xyScale;
if (i === startSample) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Dividers
ctx.strokeStyle = dividerColor;
ctx.beginPath();
ctx.moveTo(0, topHeight);
ctx.lineTo(width, topHeight);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(halfWidth, 0);
ctx.lineTo(halfWidth, topHeight);
ctx.stroke();
}
};
// Setup recording
const videoStream = canvas.captureStream(fps);
const audioContext = new AudioContext();
await audioContext.resume();
const audioArrayBuffer = await audioFile.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(audioArrayBuffer);
const audioSource = audioContext.createBufferSource();
audioSource.buffer = audioBuffer;
const audioDestination = audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
const combinedStream = new MediaStream([
...videoStream.getVideoTracks(),
...audioDestination.stream.getAudioTracks(),
]);
let mimeType = 'video/webm;codecs=vp8,opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'video/webm;codecs=vp9,opus';
}
if (!MediaRecorder.isTypeSupported(mimeType)) {
mimeType = 'video/webm';
}
const mediaRecorder = new MediaRecorder(combinedStream, {
mimeType,
videoBitsPerSecond: 8000000,
audioBitsPerSecond: 256000,
});
const chunks: Blob[] = [];
return new Promise<string>((resolve, reject) => {
let stopped = false;
const stopRecorder = (reason: string) => {
if (stopped) return;
stopped = true;
log('stopRecorder', reason);
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
};
mediaRecorder.ondataavailable = (e) => {
log('ondataavailable', { size: e.data?.size, type: e.data?.type });
if (e.data && e.data.size > 0) {
chunks.push(e.data);
}
};
mediaRecorder.onstop = async () => {
log('onstop', { chunks: chunks.length });
await audioContext.close();
combinedStream.getTracks().forEach(t => t.stop());
const blob = new Blob(chunks, { type: mimeType });
log('final blob', { size: blob.size });
if (blob.size === 0) {
reject(new Error('Empty blob'));
return;
}
resolve(URL.createObjectURL(blob));
};
mediaRecorder.onerror = (e) => reject(e);
audioSource.onended = () => {
log('audioSource.onended');
renderFrame(Math.max(0, totalSamples - samplesPerFrame));
stopRecorder('audio_ended');
};
// Start recording
mediaRecorder.start();
const exportStart = audioContext.currentTime;
audioSource.start(0);
log('started', { duration: audioBuffer.duration });
// Safety timeout
setTimeout(() => stopRecorder('timeout'), (audioBuffer.duration + 30) * 1000);
// Render loop
let lastFrame = -1;
const loop = () => {
if (stopped) return;
const t = Math.max(0, audioContext.currentTime - exportStart);
const frameIndex = Math.floor(t * fps);
if (frameIndex !== lastFrame) {
renderFrame(Math.min(frameIndex * samplesPerFrame, totalSamples - 1));
lastFrame = frameIndex;
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
});
}
async validateBlob(url: string): Promise<ValidationResult> {
const issues: string[] = [];
try {
const response = await fetch(url);
const blob = await response.blob();
if (blob.size === 0) {
issues.push('Blob is empty');
}
if (!blob.type.includes('webm')) {
issues.push(`Unexpected MIME type: ${blob.type}`);
}
// Check WebM magic bytes
const header = await blob.slice(0, 4).arrayBuffer();
const bytes = new Uint8Array(header);
// WebM starts with 0x1A 0x45 0xDF 0xA3 (EBML header)
if (bytes[0] !== 0x1A || bytes[1] !== 0x45 || bytes[2] !== 0xDF || bytes[3] !== 0xA3) {
issues.push('Invalid WebM header (missing EBML magic bytes)');
}
return {
valid: issues.length === 0,
size: blob.size,
type: blob.type,
issues,
};
} catch (error) {
return {
valid: false,
size: 0,
type: '',
issues: [error instanceof Error ? error.message : String(error)],
};
}
}
downloadBlob(url: string, filename: string = 'test-export.webm') {
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
}
}
// Expose globally for testing
const api = new VideoExportTestAPIClass();
declare global {
interface Window {
VideoExportTestAPI: VideoExportTestAPIClass;
}
}
if (typeof window !== 'undefined') {
window.VideoExportTestAPI = api;
}
export const VideoExportTestAPI = api;