Compare commits

...

11 Commits

Author SHA1 Message Date
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
13 changed files with 326 additions and 570 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-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: /
+4 -9
View File
@@ -7,18 +7,13 @@ const MatrixCursor = () => {
const [isTouchDevice, setIsTouchDevice] = useState(false); const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => { 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 checkTouchDevice = () => {
const hasTouchScreen = 'ontouchstart' in window || // Check if the device has a fine pointer (mouse) as primary input
navigator.maxTouchPoints > 0 ||
// @ts-ignore - msMaxTouchPoints is IE-specific
navigator.msMaxTouchPoints > 0;
// Also check if the device has a fine pointer (mouse)
const hasFinePrimary = window.matchMedia('(pointer: fine)').matches; const hasFinePrimary = window.matchMedia('(pointer: fine)').matches;
// Consider it a touch device if it has touch AND doesn't have fine pointer as primary // Consider it a touch device only if it doesn't have a fine pointer as primary
setIsTouchDevice(hasTouchScreen && !hasFinePrimary); setIsTouchDevice(!hasFinePrimary);
}; };
checkTouchDevice(); checkTouchDevice();
+9
View File
@@ -41,6 +41,12 @@ export const useSwipeControls = ({
useEffect(() => { useEffect(() => {
if (!enabled) return; 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 handleTouchStart = (e: TouchEvent) => {
const touch = e.touches[0]; const touch = e.touches[0];
swipeStateRef.current = { swipeStateRef.current = {
@@ -99,6 +105,9 @@ export const useSwipeControls = ({
document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchCancel); document.removeEventListener('touchcancel', handleTouchCancel);
// Restore body scroll
document.body.style.overflow = prevOverflow;
document.body.style.touchAction = prevTouchAction;
}; };
}, [enabled, minSwipeDistance, fastSwipeThreshold]); }, [enabled, minSwipeDistance, fastSwipeThreshold]);
+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%;
+5 -2
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"
@@ -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 ${ 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' message.role === 'user'
? 'bg-primary/20 border border-primary/50' ? 'bg-primary/20 border border-primary/50'
: 'bg-secondary/50 border border-primary/30' : 'bg-muted border border-primary/30'
}`} }`}
> >
<MessageContent <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"> <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" /> <Bot className="w-4 h-4 text-primary" />
</div> </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" /> <Loader2 className="w-4 h-4 text-primary animate-spin" />
</div> </div>
</motion.div> </motion.div>
+53 -8
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;
@@ -104,10 +106,26 @@ const Breakout = () => {
if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; } if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; }
return { width: Math.floor(width), height: Math.floor(height) }; return { width: Math.floor(width), height: Math.floor(height) };
} }
if (isTwoPlayer) { if (isFullscreen) {
return isFullscreen ? { width: 480, height: 580 } : { width: 400, height: 500 }; const aspectRatio = 560 / 680;
if (isTwoPlayer) {
// 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) };
}
// 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 isFullscreen ? { width: 700, height: 840 } : { width: 560, height: 680 }; return isTwoPlayer ? { width: 400, height: 500 } : { width: 560, height: 680 };
}, [isFullscreen, isTwoPlayer]); }, [isFullscreen, isTwoPlayer]);
const [canvasSize, setCanvasSize] = useState(getCanvasSize); const [canvasSize, setCanvasSize] = useState(getCanvasSize);
@@ -132,6 +150,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 +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 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]);
@@ -261,6 +289,16 @@ const Breakout = () => {
paddleXRef.current = Math.max(0, Math.min(canvasSize.width - paddleWidth, x - paddleWidth / 2)); paddleXRef.current = Math.max(0, Math.min(canvasSize.width - paddleWidth, x - paddleWidth / 2));
}, [gameStarted, gameOver, isPaused, canvasSize.width, paddleWidth]); }, [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 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]); 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'); const ctx = canvas.getContext('2d');
if (!ctx) return; 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(leftKey)) paddleRefParam.current = Math.max(0, paddleRefParam.current - paddleSpeed);
if (keysRef.current.has(rightKey)) paddleRefParam.current = Math.min(canvasSize.width - paddleWidth, 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()} />; 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>
@@ -547,7 +587,7 @@ const Breakout = () => {
<div className={`flex ${isTwoPlayer ? 'gap-4' : ''}`}> <div className={`flex ${isTwoPlayer ? 'gap-4' : ''}`}>
{/* P1 Board */} {/* P1 Board */}
<div className="border-2 border-primary box-glow bg-background/80"> <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> </div>
{/* P2 Board */} {/* P2 Board */}
@@ -702,6 +742,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;
-10
View File
@@ -8,7 +8,6 @@ const games = [
id: 'tetris', id: 'tetris',
name: 'Tetris', name: 'Tetris',
description: 'Classic block-stacking puzzle game', description: 'Classic block-stacking puzzle game',
hasMultiplayer: true,
ascii: `┌────────┐ ascii: `┌────────┐
│ ▓▓ │ │ ▓▓ │
│ ▓▓ ██ │ │ ▓▓ ██ │
@@ -20,7 +19,6 @@ const games = [
id: 'pacman', id: 'pacman',
name: 'Pac-Man', name: 'Pac-Man',
description: 'Navigate the maze, eat dots, avoid ghosts', description: 'Navigate the maze, eat dots, avoid ghosts',
hasMultiplayer: true,
ascii: `┌────────┐ ascii: `┌────────┐
│· · ᗣ · │ │· · ᗣ · │
│ ┌─┐ ┌─┐│ │ ┌─┐ ┌─┐│
@@ -32,7 +30,6 @@ const games = [
id: 'snake', id: 'snake',
name: 'Snake', name: 'Snake',
description: 'Eat food, grow longer, dont hit yourself', description: 'Eat food, grow longer, dont hit yourself',
hasMultiplayer: true,
ascii: `┌────────┐ ascii: `┌────────┐
│ │ │ │
│ ●■■■ │ │ ●■■■ │
@@ -44,7 +41,6 @@ const games = [
id: 'breakout', id: 'breakout',
name: 'Breakout', name: 'Breakout',
description: 'Break bricks with a bouncing ball', description: 'Break bricks with a bouncing ball',
hasMultiplayer: true,
ascii: `┌────────┐ ascii: `┌────────┐
│████████│ │████████│
│▓▓▓▓▓▓▓▓│ │▓▓▓▓▓▓▓▓│
@@ -110,12 +106,6 @@ const Games = () => {
</p> </p>
</div> </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> </div>
</Link> </Link>
</motion.div> </motion.div>
+168 -42
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;
@@ -68,6 +70,10 @@ const Pacman = () => {
const [player1Eliminated, setPlayer1Eliminated] = useState(false); const [player1Eliminated, setPlayer1Eliminated] = useState(false);
const [player2Eliminated, setPlayer2Eliminated] = useState(false); const [player2Eliminated, setPlayer2Eliminated] = useState(false);
const [showEliminationPrompt, setShowEliminationPrompt] = 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 }[]>([ const [ghosts, setGhosts] = useState<{ pos: Position; dir: Direction; eaten: boolean }[]>([
{ pos: { x: 9, y: 9 }, dir: 'left', eaten: false }, { pos: { x: 9, y: 9 }, dir: 'left', eaten: false },
{ pos: { x: 10, y: 9 }, dir: 'up', eaten: false }, { pos: { x: 10, y: 9 }, dir: 'up', eaten: false },
@@ -86,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);
@@ -100,7 +122,13 @@ const Pacman = () => {
const maxHeight = window.innerHeight - 420; const maxHeight = window.innerHeight - 420;
return Math.min(Math.floor(maxWidth / GRID_WIDTH), Math.floor(maxHeight / GRID_HEIGHT), 18); 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]); }, [isFullscreen]);
const [cellSize, setCellSize] = useState(getCellSize); const [cellSize, setCellSize] = useState(getCellSize);
@@ -133,6 +161,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(); }
@@ -194,14 +231,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();
@@ -211,10 +261,39 @@ const Pacman = () => {
setGameMode('1p'); setGameMode('1p');
setShowEliminationPrompt(false); setShowEliminationPrompt(false);
setIsPaused(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'); 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(() => { useEffect(() => {
if (!gameStarted || gameOver || gameComplete || isPaused) return; if (!gameStarted || gameOver || gameComplete || isPaused) return;
@@ -260,35 +339,45 @@ const Pacman = () => {
}); });
playSound('success'); playSound('success');
} else if (!ghostAtTarget.eaten) { } else if (!ghostAtTarget.eaten) {
// Hit by ghost - lose life without moving // Check invulnerability
const isPlayer1 = player === pacman; const isPlayer1 = setPlayer === setPacman;
const currentLives = isPlayer1 ? lives : lives2; const isInvulnerable = isPlayer1 ? p1Invulnerable : p2Invulnerable;
const setLivesFunc = isPlayer1 ? setLives : setLives2;
const newLives = currentLives - 1; if (!isInvulnerable) {
setLivesFunc(newLives); // Hit by ghost - lose life without moving
const currentLives = isPlayer1 ? lives : lives2;
const setLivesFunc = isPlayer1 ? setLives : setLives2;
if (newLives > 0) { const newLives = currentLives - 1;
// Respawn at start setLivesFunc(newLives);
setPlayer({ x: 10, y: 15 });
playSound('error'); if (newLives > 0) {
// Respawn at start with invulnerability
setPlayer({ x: 10, y: 15 });
if (isPlayer1) makeP1Invulnerable();
else makeP2Invulnerable();
playSound('error');
} else {
// Player eliminated
if (isPlayer1) {
setPlayer1Eliminated(true);
setPacman({ x: -1, y: -1 });
} else {
setPlayer2Eliminated(true);
setPacman2({ x: -1, y: -1 });
}
playSound('error');
if (gameMode === '2p' && ((isPlayer1 && !player2Eliminated) || (!isPlayer1 && !player1Eliminated))) {
setShowEliminationPrompt(true);
setIsPaused(true);
} else {
setGameOver(true);
}
}
} else { } else {
// Player eliminated // Invulnerable - just move through
if (isPlayer1) { setPlayer(newPos);
setPlayer1Eliminated(true);
setPacman({ x: -1, y: -1 });
} else {
setPlayer2Eliminated(true);
setPacman2({ x: -1, y: -1 });
}
playSound('error');
if (gameMode === '2p' && ((isPlayer1 && !player2Eliminated) || (!isPlayer1 && !player1Eliminated))) {
setShowEliminationPrompt(true);
setIsPaused(true);
} else {
setGameOver(true);
}
} }
} }
} else { } else {
@@ -308,6 +397,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);
@@ -316,6 +413,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');
}
} }
} }
}; };
@@ -337,12 +447,13 @@ const Pacman = () => {
return ns; return ns;
}); });
playSound('success'); playSound('success');
} else if (!ghost.eaten) { } else if (!ghost.eaten && !p1Invulnerable) {
// Player 1 dies // Player 1 dies
const newLives = lives - 1; const newLives = lives - 1;
setLives(newLives); setLives(newLives);
if (newLives > 0) { if (newLives > 0) {
setPacman({ x: 10, y: 15 }); setPacman({ x: 10, y: 15 });
makeP1Invulnerable();
playSound('error'); playSound('error');
} else { } else {
setPlayer1Eliminated(true); setPlayer1Eliminated(true);
@@ -402,12 +513,13 @@ const Pacman = () => {
}); });
playSound('success'); playSound('success');
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g); 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 // Ghost kills player
const newLives = lives - 1; const newLives = lives - 1;
setLives(newLives); setLives(newLives);
if (newLives > 0) { if (newLives > 0) {
setPacman({ x: 10, y: 15 }); setPacman({ x: 10, y: 15 });
makeP1Invulnerable();
playSound('error'); playSound('error');
} else { } else {
setPlayer1Eliminated(true); setPlayer1Eliminated(true);
@@ -443,12 +555,13 @@ const Pacman = () => {
}); });
playSound('success'); playSound('success');
return currentGhosts.map((g, idx) => idx === i ? { ...g, eaten: true, pos: { x: 10, y: 9 } } : g); 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 // Ghost kills player
const newLives = lives2 - 1; const newLives = lives2 - 1;
setLives2(newLives); setLives2(newLives);
if (newLives > 0) { if (newLives > 0) {
setPacman2({ x: 10, y: 15 }); setPacman2({ x: 10, y: 15 });
makeP2Invulnerable();
playSound('error'); playSound('error');
} else { } else {
setPlayer2Eliminated(true); setPlayer2Eliminated(true);
@@ -469,7 +582,7 @@ const Pacman = () => {
} }
}, TICK_SPEED); }, TICK_SPEED);
return () => clearInterval(interval); 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 = () => ( const renderPacman2 = () => (
<svg viewBox="0 0 100 100" className="w-full h-full transition-transform duration-100 ease-out" style={{ transform: `rotate(${getPacmanRotation2()}deg)` }}> <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" />} {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" /> <circle cx="50" cy="25" r="6" fill="hsl(var(--background))" className="transition-all duration-100 ease-out" />
</svg> </svg>
@@ -602,12 +715,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" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(var(--primary)))' }}>{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" style={{ width: spriteSize, height: spriteSize, transition: 'all 0.2s ease-out', filter: 'drop-shadow(0 0 4px hsl(120 70% 50%))' }}>{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(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>} {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>
); );
@@ -618,9 +737,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>
@@ -649,8 +770,8 @@ const Pacman = () => {
<p className="font-minecraft text-lg text-primary text-glow">{score.toLocaleString()}</p> <p className="font-minecraft text-lg text-primary text-glow">{score.toLocaleString()}</p>
</div> </div>
<div className="text-center"> <div className="text-center">
<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-minecraft text-lg" style={{ color: 'hsl(120 70% 50%)', textShadow: '0 0 10px hsl(120 70% 50%)' }}>{score2.toLocaleString()}</p> <p className="font-minecraft text-lg text-secondary" style={{ textShadow: '0 0 10px hsl(var(--secondary))' }}>{score2.toLocaleString()}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -680,7 +801,7 @@ const Pacman = () => {
<div className="flex justify-center gap-2 mt-1"> <div className="flex justify-center gap-2 mt-1">
<span className="font-minecraft text-sm text-primary">{lives}</span> <span className="font-minecraft text-sm text-primary">{lives}</span>
<span className="text-foreground/30">/</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>
</div> </div>
) : ( ) : (
@@ -701,7 +822,7 @@ const Pacman = () => {
<p className="font-pixel text-[9px] text-foreground/60">WASD</p> <p className="font-pixel text-[9px] text-foreground/60">WASD</p>
</div> </div>
<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> <p className="font-pixel text-[9px] text-foreground/60">IJKL</p>
</div> </div>
</div> </div>
@@ -847,6 +968,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;
+33 -6
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,9 +10,10 @@ 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 = 150;
const MAX_SCORE = 4294967296; const MAX_SCORE = 4294967296;
const HIGHSCORE_KEY = 'snake-highscore'; const HIGHSCORE_KEY = 'snake-highscore';
@@ -76,10 +78,19 @@ const Snake = () => {
const maxHeight = window.innerHeight - 420; const maxHeight = window.innerHeight - 420;
return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 20); return Math.min(Math.floor(maxWidth / GRID_SIZE), Math.floor(maxHeight / GRID_SIZE), 20);
} }
if (gameMode === '2p') { if (isFullscreen) {
return isFullscreen ? 24 : 20; if (gameMode === '2p') {
// 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));
}
// 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 isFullscreen ? 32 : 28; return gameMode === '2p' ? 20 : 28;
}, [isFullscreen, gameMode]); }, [isFullscreen, gameMode]);
const [cellSize, setCellSize] = useState(getCellSize); const [cellSize, setCellSize] = useState(getCellSize);
@@ -117,6 +128,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 +553,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 +761,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;
+50 -21
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;
@@ -130,8 +132,16 @@ const Tetris = () => {
const maxHeight = window.innerHeight - 320; const maxHeight = window.innerHeight - 320;
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 20); return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 20);
} }
if (gameMode === '2p') return isFullscreen ? 26 : 22; if (isFullscreen) {
return isFullscreen ? 34 : 28; // 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, gameMode === '2p' ? 32 : 40);
}
if (gameMode === '2p') return 22;
return 28;
}, [isFullscreen, gameMode]); }, [isFullscreen, gameMode]);
const [cellSize, setCellSize] = useState(getCellSize); const [cellSize, setCellSize] = useState(getCellSize);
@@ -152,6 +162,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);
@@ -339,31 +358,26 @@ const Tetris = () => {
const movePlayer = useCallback((playerNum: 1 | 2, action: 'left' | 'right' | 'down' | 'rotate' | 'drop') => { const movePlayer = useCallback((playerNum: 1 | 2, action: 'left' | 'right' | 'down' | 'rotate' | 'drop') => {
if (gameOver || isPaused || !gameStarted || gameMode !== '2p') return; if (gameOver || isPaused || !gameStarted || gameMode !== '2p') return;
const setPlayer = playerNum === 1 ? setPlayer1 : setPlayer2; const setPlayer = playerNum === 1 ? setPlayer1 : setPlayer2;
const player = playerNum === 1 ? player1 : player2;
const isP2 = playerNum === 2; const isP2 = playerNum === 2;
if (player.gameOver) return;
setPlayer(prev => { setPlayer(prev => {
if (prev.gameOver) return prev;
let newPiece = { ...prev.piece }; let newPiece = { ...prev.piece };
switch (action) { switch (action) {
case 'left': case 'left':
newPiece.x--; newPiece.x--;
if (!isValidMove(newPiece, prev.board)) return prev; if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece }; return { ...prev, piece: newPiece };
case 'right': case 'right':
newPiece.x++; newPiece.x++;
if (!isValidMove(newPiece, prev.board)) return prev; if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece }; return { ...prev, piece: newPiece };
case 'rotate': case 'rotate':
newPiece.shape = rotate(prev.piece.shape); newPiece.shape = rotate(prev.piece.shape);
if (!isValidMove(newPiece, prev.board)) return prev; if (!isValidMove(newPiece, prev.board)) return prev;
playSound('hover');
return { ...prev, piece: newPiece }; return { ...prev, piece: newPiece };
case 'drop': case 'drop':
while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, prev.board)) newPiece.y++; while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, prev.board)) newPiece.y++;
playSound('click');
return { ...prev, piece: newPiece }; return { ...prev, piece: newPiece };
case 'down': case 'down':
newPiece.y++; newPiece.y++;
@@ -373,12 +387,9 @@ const Tetris = () => {
const { board: clearedBoard, cleared } = clearLines(mergedBoard); const { board: clearedBoard, cleared } = clearLines(mergedBoard);
const newScore = prev.score + (cleared > 0 ? cleared * 100 * cleared : 0); const newScore = prev.score + (cleared > 0 ? cleared * 100 * cleared : 0);
const newLines = prev.lines + cleared; const newLines = prev.lines + cleared;
if (cleared > 0) playSound('success');
else playSound('click');
const newTetromino = prev.nextPiece; const newTetromino = prev.nextPiece;
const nextNext = randomTetromino(isP2, prev.nextPiece.shape); const nextNext = randomTetromino(isP2, prev.nextPiece.shape);
if (!isValidMove(newTetromino, clearedBoard)) { if (!isValidMove(newTetromino, clearedBoard)) {
playSound('error');
return { ...prev, board: clearedBoard, score: newScore, lines: newLines, gameOver: true }; return { ...prev, board: clearedBoard, score: newScore, lines: newLines, gameOver: true };
} }
return { ...prev, board: clearedBoard, piece: newTetromino, nextPiece: nextNext, score: newScore, lines: newLines }; return { ...prev, board: clearedBoard, piece: newTetromino, nextPiece: nextNext, score: newScore, lines: newLines };
@@ -386,7 +397,7 @@ const Tetris = () => {
} }
return prev; return prev;
}); });
}, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]); }, [gameOver, isPaused, gameStarted, gameMode, isValidMove, mergePiece, clearLines]);
useEffect(() => { useEffect(() => {
if (gameMode !== '2p' || !gameStarted) return; if (gameMode !== '2p' || !gameStarted) return;
@@ -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 }} <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>
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong"> <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> </h1>
</div> </div>
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}> <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"> <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> <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> <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">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><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>
<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> </div>
{/* P2 Board */}
<div className="flex flex-col items-center gap-2"> <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> <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> <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">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><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>
</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"> <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)} {renderNextPiece(player2.nextPiece)}
</div> </div>
</div> </div>
@@ -698,6 +722,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;
-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;