Compare commits
12 Commits
ccca90c504
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f2fd37e29 | |||
| c0c73f9e44 | |||
| 35bb767c73 | |||
| 95a1034667 | |||
| 30741d1490 | |||
| dde1042223 | |||
| 51928560be | |||
| 147b8cb43f | |||
| f4b2916693 | |||
| d1c482f5c9 | |||
| bd1603a7b3 | |||
| 07a2fe0c17 |
BIN
Binary file not shown.
Generated
+18
-18
@@ -2342,9 +2342,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -6042,9 +6042,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.castarray": {
|
"node_modules/lodash.castarray": {
|
||||||
@@ -6977,9 +6977,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7157,12 +7157,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.1",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.0"
|
"@remix-run/router": "1.23.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -7172,13 +7172,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.30.1",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.23.0",
|
"@remix-run/router": "1.23.2",
|
||||||
"react-router": "6.30.1"
|
"react-router": "6.30.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
User-agent: Googlebot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Bingbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Twitterbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: facebookexternalhit
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
@@ -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();
|
||||||
@@ -79,7 +74,13 @@ const MatrixCursor = () => {
|
|||||||
document.removeEventListener('mouseenter', handleMouseEnter);
|
document.removeEventListener('mouseenter', handleMouseEnter);
|
||||||
document.removeEventListener('mouseleave', handleMouseLeave);
|
document.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
document.removeEventListener('touchstart', handleTouch);
|
document.removeEventListener('touchstart', handleTouch);
|
||||||
document.body.classList.remove('matrix-cursor');
|
|
||||||
|
// 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]);
|
}, [isTouchDevice]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import { Settings, Monitor, Volume2, Cpu, X, Contrast, Binary, Smartphone } from 'lucide-react';
|
import { Settings, Monitor, Volume2, Cpu, X, Contrast, Binary, Smartphone } from 'lucide-react';
|
||||||
import { useSettings, MobileControlMode } from '@/contexts/SettingsContext';
|
import { useSettings, MobileControlMode } from '@/contexts/SettingsContext';
|
||||||
import { useAchievements } from '@/contexts/AchievementsContext';
|
import { useAchievements } from '@/contexts/AchievementsContext';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import CryptoConsentModal from './CryptoConsentModal';
|
import CryptoConsentModal from './CryptoConsentModal';
|
||||||
|
|
||||||
interface SettingsPanelProps {
|
interface SettingsPanelProps {
|
||||||
@@ -15,6 +16,7 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => {
|
|||||||
const [showCryptoModal, setShowCryptoModal] = useState(false);
|
const [showCryptoModal, setShowCryptoModal] = useState(false);
|
||||||
const { crtEnabled, setCrtEnabled, matrixEnabled, setMatrixEnabled, soundEnabled, setSoundEnabled, cryptoConsent, playSound, mobileControlMode, setMobileControlMode } = useSettings();
|
const { crtEnabled, setCrtEnabled, matrixEnabled, setMatrixEnabled, soundEnabled, setSoundEnabled, cryptoConsent, playSound, mobileControlMode, setMobileControlMode } = useSettings();
|
||||||
const { unlockAchievement } = useAchievements();
|
const { unlockAchievement } = useAchievements();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleToggle = (setter: (value: boolean) => void, currentValue: boolean, achievementId?: string) => {
|
const handleToggle = (setter: (value: boolean) => void, currentValue: boolean, achievementId?: string) => {
|
||||||
playSound('click');
|
playSound('click');
|
||||||
@@ -156,18 +158,20 @@ const SettingsPanel = ({ onToggleTheme, isRedTheme }: SettingsPanelProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Controls Mode */}
|
{/* Mobile Controls Mode */}
|
||||||
<div className="flex items-center justify-between">
|
{isMobile && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Smartphone size={16} className="text-primary" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-pixel text-sm text-foreground/90">Mobile Controls</span>
|
<Smartphone size={16} className="text-primary" />
|
||||||
|
<span className="font-pixel text-sm text-foreground/90">Game Controls (for touch devices)</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleMobileControlToggle}
|
||||||
|
className="px-3 py-1 text-xs font-pixel border border-primary transition-all duration-300 bg-transparent text-primary hover:bg-primary hover:text-background"
|
||||||
|
>
|
||||||
|
{mobileControlMode === 'buttons' ? 'BUTTONS' : 'SWIPE'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={handleMobileControlToggle}
|
|
||||||
className="px-3 py-1 text-xs font-pixel border border-primary transition-all duration-300 bg-transparent text-primary hover:bg-primary hover:text-background"
|
|
||||||
>
|
|
||||||
{mobileControlMode === 'buttons' ? 'BUTTONS' : 'SWIPE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-primary/30">
|
<div className="mt-4 pt-4 border-t border-primary/30">
|
||||||
|
|||||||
@@ -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
@@ -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%;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
|
if (!isInvulnerable) {
|
||||||
|
// Hit by ghost - lose life without moving
|
||||||
|
const currentLives = isPlayer1 ? lives : lives2;
|
||||||
|
const setLivesFunc = isPlayer1 ? setLives : setLives2;
|
||||||
|
|
||||||
const newLives = currentLives - 1;
|
const newLives = currentLives - 1;
|
||||||
setLivesFunc(newLives);
|
setLivesFunc(newLives);
|
||||||
|
|
||||||
if (newLives > 0) {
|
if (newLives > 0) {
|
||||||
// Respawn at start
|
// Respawn at start with invulnerability
|
||||||
setPlayer({ x: 10, y: 15 });
|
setPlayer({ x: 10, y: 15 });
|
||||||
playSound('error');
|
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;
|
||||||
|
|||||||
+35
-8
@@ -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,20 +553,22 @@ 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>
|
||||||
<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">
|
||||||
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>
|
</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'}>
|
||||||
@@ -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;
|
||||||
|
|||||||
+51
-22
@@ -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);
|
||||||
|
}
|
||||||
|
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;
|
||||||
@@ -497,7 +508,7 @@ const Tetris = () => {
|
|||||||
{nextP.shape.map((row, y) => (
|
{nextP.shape.map((row, y) => (
|
||||||
<div key={y} className="flex gap-1">
|
<div key={y} className="flex gap-1">
|
||||||
{row.map((val, x) => (
|
{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>
|
</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 }}
|
<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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user