Changes
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type SwipeDirection = 'up' | 'down' | 'left' | 'right';
|
||||||
|
|
||||||
|
interface SwipeState {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSwipeControlsOptions {
|
||||||
|
onSwipe: (direction: SwipeDirection, isFastSwipe: boolean) => void;
|
||||||
|
onTap?: () => void;
|
||||||
|
minSwipeDistance?: number;
|
||||||
|
fastSwipeThreshold?: number; // pixels per millisecond
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for detecting swipe gestures on touch devices
|
||||||
|
* Supports directional swipes and fast swipe detection (for hard drop in Tetris)
|
||||||
|
*/
|
||||||
|
export const useSwipeControls = ({
|
||||||
|
onSwipe,
|
||||||
|
onTap,
|
||||||
|
minSwipeDistance = 30,
|
||||||
|
fastSwipeThreshold = 1.5,
|
||||||
|
enabled = true,
|
||||||
|
}: UseSwipeControlsOptions) => {
|
||||||
|
const swipeStateRef = useRef<SwipeState | null>(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: TouchEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
swipeStateRef.current = {
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
startTime: Date.now(),
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback((e: TouchEvent) => {
|
||||||
|
if (!enabled || !swipeStateRef.current) return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const { startX, startY, startTime } = swipeStateRef.current;
|
||||||
|
|
||||||
|
const deltaX = touch.clientX - startX;
|
||||||
|
const deltaY = touch.clientY - startY;
|
||||||
|
const deltaTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
const absX = Math.abs(deltaX);
|
||||||
|
const absY = Math.abs(deltaY);
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
const speed = distance / Math.max(deltaTime, 1);
|
||||||
|
|
||||||
|
// If movement is too small, treat as tap
|
||||||
|
if (distance < minSwipeDistance) {
|
||||||
|
onTap?.();
|
||||||
|
swipeStateRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFastSwipe = speed > fastSwipeThreshold;
|
||||||
|
|
||||||
|
// Determine direction based on larger axis
|
||||||
|
let direction: SwipeDirection;
|
||||||
|
if (absX > absY) {
|
||||||
|
direction = deltaX > 0 ? 'right' : 'left';
|
||||||
|
} else {
|
||||||
|
direction = deltaY > 0 ? 'down' : 'up';
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipe(direction, isFastSwipe);
|
||||||
|
swipeStateRef.current = null;
|
||||||
|
}, [enabled, minSwipeDistance, fastSwipeThreshold, onSwipe, onTap]);
|
||||||
|
|
||||||
|
const handleTouchCancel = useCallback(() => {
|
||||||
|
swipeStateRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Returns handlers to attach to an element
|
||||||
|
const getSwipeHandlers = useCallback(() => ({
|
||||||
|
onTouchStart: (e: React.TouchEvent) => handleTouchStart(e.nativeEvent),
|
||||||
|
onTouchEnd: (e: React.TouchEvent) => handleTouchEnd(e.nativeEvent),
|
||||||
|
onTouchCancel: () => handleTouchCancel(),
|
||||||
|
}), [handleTouchStart, handleTouchEnd, handleTouchCancel]);
|
||||||
|
|
||||||
|
// Alternative: attach to a specific element ref
|
||||||
|
const bindToElement = useCallback((element: HTMLElement | null) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
element.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
element.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
element.removeEventListener('touchcancel', handleTouchCancel);
|
||||||
|
};
|
||||||
|
}, [handleTouchStart, handleTouchEnd, handleTouchCancel]);
|
||||||
|
|
||||||
|
return { getSwipeHandlers, bindToElement };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSwipeControls;
|
||||||
@@ -389,7 +389,7 @@ const AIChat = () => {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className={`flex flex-col ${
|
className={`flex flex-col ${
|
||||||
isFullscreen
|
isFullscreen
|
||||||
? 'fixed inset-0 z-50 bg-background p-3 md:p-8 overflow-hidden'
|
? 'fixed inset-0 z-50 bg-background p-2 sm:p-3 md:p-8 overflow-hidden w-screen h-screen'
|
||||||
: 'h-full'
|
: 'h-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -559,7 +559,7 @@ const AIChat = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`max-w-[85%] sm:max-w-[80%] p-2 sm:p-3 rounded-lg text-sm ${
|
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-secondary/50 border border-primary/30'
|
||||||
|
|||||||
+24
-14
@@ -6,8 +6,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import GlitchCrash from '@/components/GlitchCrash';
|
import GlitchCrash from '@/components/GlitchCrash';
|
||||||
import { Maximize2, Minimize2 } from 'lucide-react';
|
import { Maximize2, Minimize2 } from 'lucide-react';
|
||||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||||
import GameTouchButton from '@/components/GameTouchButton';
|
|
||||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||||
|
import { useSwipeControls } from '@/hooks/useSwipeControls';
|
||||||
|
|
||||||
const GRID_WIDTH = 21;
|
const GRID_WIDTH = 21;
|
||||||
const GRID_HEIGHT = 21;
|
const GRID_HEIGHT = 21;
|
||||||
@@ -105,6 +105,19 @@ const Pacman = () => {
|
|||||||
const [cellSize, setCellSize] = useState(getCellSize);
|
const [cellSize, setCellSize] = useState(getCellSize);
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
|
// Swipe controls for mobile
|
||||||
|
const { getSwipeHandlers } = useSwipeControls({
|
||||||
|
onSwipe: (dir) => {
|
||||||
|
if (gameMode === '1p' && gameStarted && !gameOver && !gameComplete && !isPaused) {
|
||||||
|
setNextDirection(dir as Direction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap: () => {
|
||||||
|
if (gameStarted && !gameOver && !gameComplete) setIsPaused(p => !p);
|
||||||
|
},
|
||||||
|
enabled: isMobile && gameStarted && !gameOver && !gameComplete,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setCellSize(getCellSize());
|
const handleResize = () => setCellSize(getCellSize());
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
@@ -731,21 +744,18 @@ const Pacman = () => {
|
|||||||
|
|
||||||
{showModeSelection ? (
|
{showModeSelection ? (
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
<p className="font-pixel text-[10px] text-foreground/60 text-center">SELECT MODE</p>
|
<p className="font-pixel text-[10px] text-foreground/60 text-center">MOBILE: 1 PLAYER ONLY</p>
|
||||||
<button onClick={() => { setGameMode('1p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">1 PLAYER</button>
|
<button onClick={() => { setGameMode('1p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">START GAME</button>
|
||||||
<button onClick={() => { setGameMode('2p'); setShowModeSelection(false); }} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">2 PLAYERS</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : gameStarted && !gameOver && !gameComplete ? (
|
) : gameStarted && !gameOver && !gameComplete ? (
|
||||||
<div className="grid grid-cols-3 gap-1 mt-2">
|
<div
|
||||||
<div />
|
className="w-full h-24 border border-primary/30 rounded-lg bg-primary/5 flex items-center justify-center touch-none mt-2"
|
||||||
<GameTouchButton onAction={() => setNextDirection('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↑</GameTouchButton>
|
{...getSwipeHandlers()}
|
||||||
<div />
|
>
|
||||||
<GameTouchButton onAction={() => setNextDirection('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">←</GameTouchButton>
|
<p className="font-pixel text-xs text-primary/60 text-center pointer-events-none">
|
||||||
<button onClick={() => setIsPaused(p => !p)} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
|
SWIPE TO MOVE<br />
|
||||||
<GameTouchButton onAction={() => setNextDirection('right')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→</GameTouchButton>
|
<span className="text-[10px] text-foreground/40">TAP TO PAUSE</span>
|
||||||
<div />
|
</p>
|
||||||
<GameTouchButton onAction={() => setNextDirection('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓</GameTouchButton>
|
|
||||||
<div />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={startGame} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
<button onClick={startGame} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
||||||
|
|||||||
+22
-11
@@ -6,8 +6,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import GlitchCrash from '@/components/GlitchCrash';
|
import GlitchCrash from '@/components/GlitchCrash';
|
||||||
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
||||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||||
import GameTouchButton from '@/components/GameTouchButton';
|
|
||||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||||
|
import { useSwipeControls } from '@/hooks/useSwipeControls';
|
||||||
|
|
||||||
const GRID_SIZE = 20;
|
const GRID_SIZE = 20;
|
||||||
const TICK_SPEED = 120;
|
const TICK_SPEED = 120;
|
||||||
@@ -84,6 +84,19 @@ const Snake = () => {
|
|||||||
const [cellSize, setCellSize] = useState(getCellSize);
|
const [cellSize, setCellSize] = useState(getCellSize);
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
|
// Swipe controls for mobile
|
||||||
|
const handleSwipe = useCallback((direction: Direction) => {
|
||||||
|
if (gameMode === '1p') {
|
||||||
|
directionQueueRef.current.push(direction);
|
||||||
|
}
|
||||||
|
}, [gameMode]);
|
||||||
|
|
||||||
|
const { getSwipeHandlers } = useSwipeControls({
|
||||||
|
onSwipe: (dir) => handleSwipe(dir as Direction),
|
||||||
|
onTap: () => setIsPaused(p => !p),
|
||||||
|
enabled: isMobile && gameStarted && !gameOver && !gameComplete,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => setCellSize(getCellSize());
|
const handleResize = () => setCellSize(getCellSize());
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
@@ -653,16 +666,14 @@ const Snake = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{gameStarted && !gameOver && !gameComplete ? (
|
{gameStarted && !gameOver && !gameComplete ? (
|
||||||
<div className="grid grid-cols-3 gap-1 mt-2">
|
<div
|
||||||
<div />
|
className="w-full h-24 border border-primary/30 rounded-lg bg-primary/5 flex items-center justify-center touch-none"
|
||||||
<GameTouchButton onAction={() => directionQueueRef.current.push('up')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↑</GameTouchButton>
|
{...getSwipeHandlers()}
|
||||||
<div />
|
>
|
||||||
<GameTouchButton onAction={() => directionQueueRef.current.push('left')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">←</GameTouchButton>
|
<p className="font-pixel text-xs text-primary/60 text-center pointer-events-none">
|
||||||
<button onClick={() => setIsPaused(p => !p)} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
|
SWIPE TO MOVE<br />
|
||||||
<GameTouchButton onAction={() => directionQueueRef.current.push('right')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→</GameTouchButton>
|
<span className="text-[10px] text-foreground/40">TAP TO PAUSE</span>
|
||||||
<div />
|
</p>
|
||||||
<GameTouchButton onAction={() => directionQueueRef.current.push('down')} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓</GameTouchButton>
|
|
||||||
<div />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
||||||
|
|||||||
+43
-16
@@ -6,8 +6,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import GlitchCrash from '@/components/GlitchCrash';
|
import GlitchCrash from '@/components/GlitchCrash';
|
||||||
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
import { Maximize2, Minimize2, Users, User } from 'lucide-react';
|
||||||
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
import { useBrowserFullscreen } from '@/hooks/useGameDimensions';
|
||||||
import GameTouchButton from '@/components/GameTouchButton';
|
|
||||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||||
|
import { useSwipeControls, SwipeDirection } from '@/hooks/useSwipeControls';
|
||||||
|
|
||||||
const BOARD_WIDTH = 10;
|
const BOARD_WIDTH = 10;
|
||||||
const BOARD_HEIGHT = 20;
|
const BOARD_HEIGHT = 20;
|
||||||
@@ -300,6 +300,39 @@ const Tetris = () => {
|
|||||||
playSound('click');
|
playSound('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Swipe controls for mobile - must be after function declarations
|
||||||
|
const moveLeftRef = useRef(moveLeft);
|
||||||
|
const moveRightRef = useRef(moveRight);
|
||||||
|
const moveDownRef = useRef(moveDown);
|
||||||
|
const hardDropRef = useRef(hardDrop);
|
||||||
|
const rotatePieceRef = useRef(rotatePiece);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
moveLeftRef.current = moveLeft;
|
||||||
|
moveRightRef.current = moveRight;
|
||||||
|
moveDownRef.current = moveDown;
|
||||||
|
hardDropRef.current = hardDrop;
|
||||||
|
rotatePieceRef.current = rotatePiece;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getSwipeHandlers } = useSwipeControls({
|
||||||
|
onSwipe: (dir, isFastSwipe) => {
|
||||||
|
if (gameMode !== '1p' || gameOver || gameComplete || isPaused || !gameStarted) return;
|
||||||
|
switch (dir) {
|
||||||
|
case 'left': moveLeftRef.current(); break;
|
||||||
|
case 'right': moveRightRef.current(); break;
|
||||||
|
case 'down':
|
||||||
|
if (isFastSwipe) hardDropRef.current();
|
||||||
|
else moveDownRef.current();
|
||||||
|
break;
|
||||||
|
case 'up': rotatePieceRef.current(); break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap: togglePause,
|
||||||
|
enabled: isMobile && gameStarted && !gameOver && !gameComplete,
|
||||||
|
fastSwipeThreshold: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
@@ -603,21 +636,15 @@ const Tetris = () => {
|
|||||||
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{lines}</p></div>
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{lines}</p></div>
|
||||||
</div>
|
</div>
|
||||||
{gameStarted && !gameOver ? (
|
{gameStarted && !gameOver ? (
|
||||||
<div className="flex gap-3 items-center mt-1">
|
<div
|
||||||
{/* D-pad style controls */}
|
className="w-full h-24 border border-primary/30 rounded-lg bg-primary/5 flex items-center justify-center touch-none mt-1"
|
||||||
<div className="grid grid-cols-3 gap-1">
|
{...getSwipeHandlers()}
|
||||||
<div />
|
>
|
||||||
<button onClick={rotatePiece} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg select-none">↻</button>
|
<p className="font-pixel text-xs text-primary/60 text-center pointer-events-none">
|
||||||
<div />
|
← → SWIPE TO MOVE<br />
|
||||||
<GameTouchButton onAction={moveLeft} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg" interval={100}>←</GameTouchButton>
|
↓ SOFT DROP · FAST ↓ HARD DROP<br />
|
||||||
<button onClick={hardDrop} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg select-none">⬇</button>
|
<span className="text-[10px] text-foreground/40">↑ ROTATE · TAP TO PAUSE</span>
|
||||||
<GameTouchButton onAction={moveRight} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg" interval={100}>→</GameTouchButton>
|
</p>
|
||||||
<div />
|
|
||||||
<GameTouchButton onAction={moveDown} className="p-3 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg" interval={100}>↓</GameTouchButton>
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
{/* Pause button on the side */}
|
|
||||||
<button onClick={togglePause} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{gameOver ? 'RETRY' : 'START'}</button>
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{gameOver ? 'RETRY' : 'START'}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user