Changes
This commit is contained in:
@@ -1,21 +0,0 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
interface FullscreenPortalProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Portals children to document.body to escape any transformed ancestors.
|
|
||||||
* This is important because CSS transforms on parents can break `position: fixed`.
|
|
||||||
*/
|
|
||||||
export const FullscreenPortal = ({ children }: FullscreenPortalProps) => {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted || typeof document === 'undefined') return null;
|
|
||||||
return createPortal(children, document.body);
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,6 @@ import { useAchievements, incrementAiMessageCount } from '@/contexts/Achievement
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import GlitchText from '@/components/GlitchText';
|
import GlitchText from '@/components/GlitchText';
|
||||||
import MessageContent from '@/components/MessageContent';
|
import MessageContent from '@/components/MessageContent';
|
||||||
import { FullscreenPortal } from '@/components/FullscreenPortal';
|
|
||||||
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -690,7 +689,7 @@ const AIChat = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isFullscreen ? <FullscreenPortal>{content}</FullscreenPortal> : content;
|
return content;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AIChat;
|
export default AIChat;
|
||||||
|
|||||||
+144
-150
@@ -5,10 +5,8 @@ import { useAchievements } from '@/contexts/AchievementsContext';
|
|||||||
import { Link } from 'react-router-dom';
|
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 GameTouchButton from '@/components/GameTouchButton';
|
import GameTouchButton from '@/components/GameTouchButton';
|
||||||
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock';
|
||||||
import { FullscreenPortal } from '@/components/FullscreenPortal';
|
|
||||||
|
|
||||||
const BOARD_WIDTH = 10;
|
const BOARD_WIDTH = 10;
|
||||||
const BOARD_HEIGHT = 20;
|
const BOARD_HEIGHT = 20;
|
||||||
@@ -64,9 +62,7 @@ const randomTetromino = (isP2 = false, excludeShape?: number[][]): Piece => {
|
|||||||
let key = keys[Math.floor(Math.random() * keys.length)];
|
let key = keys[Math.floor(Math.random() * keys.length)];
|
||||||
let tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
let tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||||
|
|
||||||
// Ensure we don't generate the same shape twice in a row
|
|
||||||
if (excludeShape && JSON.stringify(tetromino.shape) === JSON.stringify(excludeShape)) {
|
if (excludeShape && JSON.stringify(tetromino.shape) === JSON.stringify(excludeShape)) {
|
||||||
// Try up to 7 times to get a different shape
|
|
||||||
for (let attempts = 0; attempts < 7; attempts++) {
|
for (let attempts = 0; attempts < 7; attempts++) {
|
||||||
key = keys[Math.floor(Math.random() * keys.length)];
|
key = keys[Math.floor(Math.random() * keys.length)];
|
||||||
tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key];
|
||||||
@@ -93,10 +89,8 @@ const Tetris = () => {
|
|||||||
const { playSound } = useSettings();
|
const { playSound } = useSettings();
|
||||||
const { checkGameScoreAchievements, unlockMaxScore } = useAchievements();
|
const { checkGameScoreAchievements, unlockMaxScore } = useAchievements();
|
||||||
|
|
||||||
// Mode selection
|
|
||||||
const [gameMode, setGameMode] = useState<GameMode | null>(null);
|
const [gameMode, setGameMode] = useState<GameMode | null>(null);
|
||||||
|
|
||||||
// 1P state
|
|
||||||
const [board, setBoard] = useState<Board>(createBoard);
|
const [board, setBoard] = useState<Board>(createBoard);
|
||||||
const [piece, setPiece] = useState<Piece>(randomTetromino);
|
const [piece, setPiece] = useState<Piece>(randomTetromino);
|
||||||
const [nextPiece, setNextPiece] = useState<Piece>(randomTetromino);
|
const [nextPiece, setNextPiece] = useState<Piece>(randomTetromino);
|
||||||
@@ -104,34 +98,18 @@ const Tetris = () => {
|
|||||||
const [highScore, setHighScore] = useState(0);
|
const [highScore, setHighScore] = useState(0);
|
||||||
const [lines, setLines] = useState(0);
|
const [lines, setLines] = useState(0);
|
||||||
|
|
||||||
// 2P state
|
|
||||||
const [player1, setPlayer1] = useState<PlayerState>(() => {
|
const [player1, setPlayer1] = useState<PlayerState>(() => {
|
||||||
const p1Piece = randomTetromino();
|
const p1Piece = randomTetromino();
|
||||||
const p1Next = randomTetromino(false, p1Piece.shape);
|
const p1Next = randomTetromino(false, p1Piece.shape);
|
||||||
return {
|
return { board: createBoard(), piece: p1Piece, nextPiece: p1Next, score: 0, lines: 0, gameOver: false };
|
||||||
board: createBoard(),
|
|
||||||
piece: p1Piece,
|
|
||||||
nextPiece: p1Next,
|
|
||||||
score: 0,
|
|
||||||
lines: 0,
|
|
||||||
gameOver: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
const [player2, setPlayer2] = useState<PlayerState>(() => {
|
const [player2, setPlayer2] = useState<PlayerState>(() => {
|
||||||
const p2Piece = randomTetromino(true);
|
const p2Piece = randomTetromino(true);
|
||||||
const p2Next = randomTetromino(true, p2Piece.shape);
|
const p2Next = randomTetromino(true, p2Piece.shape);
|
||||||
return {
|
return { board: createBoard(), piece: p2Piece, nextPiece: p2Next, score: 0, lines: 0, gameOver: false };
|
||||||
board: createBoard(),
|
|
||||||
piece: p2Piece,
|
|
||||||
nextPiece: p2Next,
|
|
||||||
score: 0,
|
|
||||||
lines: 0,
|
|
||||||
gameOver: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
const [winner, setWinner] = useState<string | null>(null);
|
const [winner, setWinner] = useState<string | null>(null);
|
||||||
|
|
||||||
// Common state
|
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
const [gameComplete, setGameComplete] = useState(false);
|
const [gameComplete, setGameComplete] = useState(false);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
@@ -139,7 +117,6 @@ const Tetris = () => {
|
|||||||
const [showGlitchCrash, setShowGlitchCrash] = useState(false);
|
const [showGlitchCrash, setShowGlitchCrash] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const gameRef = useRef<HTMLDivElement>(null);
|
const gameRef = useRef<HTMLDivElement>(null);
|
||||||
const { enterFullscreen, exitFullscreen } = useBrowserFullscreen();
|
|
||||||
|
|
||||||
const getCellSize = useCallback(() => {
|
const getCellSize = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return 28;
|
if (typeof window === 'undefined') return 28;
|
||||||
@@ -149,9 +126,7 @@ const Tetris = () => {
|
|||||||
const maxHeight = window.innerHeight - 440;
|
const maxHeight = window.innerHeight - 440;
|
||||||
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 24);
|
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 24);
|
||||||
}
|
}
|
||||||
if (gameMode === '2p') {
|
if (gameMode === '2p') return isFullscreen ? 26 : 22;
|
||||||
return isFullscreen ? 26 : 22;
|
|
||||||
}
|
|
||||||
return isFullscreen ? 34 : 28;
|
return isFullscreen ? 34 : 28;
|
||||||
}, [isFullscreen, gameMode]);
|
}, [isFullscreen, gameMode]);
|
||||||
|
|
||||||
@@ -164,30 +139,24 @@ const Tetris = () => {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, [getCellSize]);
|
}, [getCellSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { setCellSize(getCellSize()); }, [isFullscreen, getCellSize, gameMode]);
|
||||||
setCellSize(getCellSize());
|
|
||||||
}, [isFullscreen, getCellSize, gameMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameStarted && isMobile && !isFullscreen) {
|
if (gameStarted && isMobile && !isFullscreen) setIsFullscreen(true);
|
||||||
setIsFullscreen(true);
|
}, [gameStarted, isMobile, isFullscreen]);
|
||||||
enterFullscreen();
|
|
||||||
}
|
|
||||||
}, [gameStarted, isMobile, isFullscreen, enterFullscreen]);
|
|
||||||
|
|
||||||
const toggleFullscreen = async () => {
|
const toggleFullscreen = () => {
|
||||||
if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); }
|
setIsFullscreen(prev => !prev);
|
||||||
else { setIsFullscreen(false); await exitFullscreen(); }
|
|
||||||
playSound('click');
|
playSound('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && isFullscreen) { setIsFullscreen(false); exitFullscreen(); }
|
if (e.key === 'Escape' && isFullscreen) setIsFullscreen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleEscape);
|
window.addEventListener('keydown', handleEscape);
|
||||||
return () => window.removeEventListener('keydown', handleEscape);
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
}, [isFullscreen, exitFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem(HIGHSCORE_KEY);
|
const saved = localStorage.getItem(HIGHSCORE_KEY);
|
||||||
@@ -236,7 +205,6 @@ const Tetris = () => {
|
|||||||
return { board: newBoard, cleared };
|
return { board: newBoard, cleared };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 1P movement functions
|
|
||||||
const moveDown = useCallback(() => {
|
const moveDown = useCallback(() => {
|
||||||
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return;
|
||||||
const newPiece = { ...piece, y: piece.y + 1 };
|
const newPiece = { ...piece, y: piece.y + 1 };
|
||||||
@@ -292,7 +260,6 @@ const Tetris = () => {
|
|||||||
|
|
||||||
const startGame = (mode: GameMode) => {
|
const startGame = (mode: GameMode) => {
|
||||||
setGameMode(mode);
|
setGameMode(mode);
|
||||||
|
|
||||||
if (mode === '1p') {
|
if (mode === '1p') {
|
||||||
setBoard(createBoard());
|
setBoard(createBoard());
|
||||||
setPiece(randomTetromino());
|
setPiece(randomTetromino());
|
||||||
@@ -300,25 +267,10 @@ const Tetris = () => {
|
|||||||
setScore(0);
|
setScore(0);
|
||||||
setLines(0);
|
setLines(0);
|
||||||
} else {
|
} else {
|
||||||
setPlayer1({
|
setPlayer1({ board: createBoard(), piece: randomTetromino(), nextPiece: randomTetromino(), score: 0, lines: 0, gameOver: false });
|
||||||
board: createBoard(),
|
setPlayer2({ board: createBoard(), piece: randomTetromino(true), nextPiece: randomTetromino(true), score: 0, lines: 0, gameOver: false });
|
||||||
piece: randomTetromino(),
|
|
||||||
nextPiece: randomTetromino(),
|
|
||||||
score: 0,
|
|
||||||
lines: 0,
|
|
||||||
gameOver: false,
|
|
||||||
});
|
|
||||||
setPlayer2({
|
|
||||||
board: createBoard(),
|
|
||||||
piece: randomTetromino(true),
|
|
||||||
nextPiece: randomTetromino(true),
|
|
||||||
score: 0,
|
|
||||||
lines: 0,
|
|
||||||
gameOver: false,
|
|
||||||
});
|
|
||||||
setWinner(null);
|
setWinner(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setGameOver(false);
|
setGameOver(false);
|
||||||
setGameComplete(false);
|
setGameComplete(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
@@ -333,82 +285,60 @@ const Tetris = () => {
|
|||||||
playSound('click');
|
playSound('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2P movement helpers
|
|
||||||
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 player = playerNum === 1 ? player1 : player2;
|
||||||
const isP2 = playerNum === 2;
|
const isP2 = playerNum === 2;
|
||||||
|
|
||||||
if (player.gameOver) return;
|
if (player.gameOver) return;
|
||||||
|
|
||||||
setPlayer(prev => {
|
setPlayer(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');
|
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');
|
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');
|
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');
|
playSound('click');
|
||||||
return { ...prev, piece: newPiece };
|
return { ...prev, piece: newPiece };
|
||||||
|
|
||||||
case 'down':
|
case 'down':
|
||||||
newPiece.y++;
|
newPiece.y++;
|
||||||
if (isValidMove(newPiece, prev.board)) {
|
if (isValidMove(newPiece, prev.board)) return { ...prev, piece: newPiece };
|
||||||
return { ...prev, piece: newPiece };
|
else {
|
||||||
} else {
|
|
||||||
const mergedBoard = mergePiece(prev.board, prev.piece);
|
const mergedBoard = mergePiece(prev.board, prev.piece);
|
||||||
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');
|
if (cleared > 0) playSound('success');
|
||||||
else playSound('click');
|
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');
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]);
|
}, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]);
|
||||||
|
|
||||||
// Check 2P win condition
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameMode !== '2p' || !gameStarted) return;
|
if (gameMode !== '2p' || !gameStarted) return;
|
||||||
|
|
||||||
if (player1.gameOver && player2.gameOver) {
|
if (player1.gameOver && player2.gameOver) {
|
||||||
setGameOver(true);
|
setGameOver(true);
|
||||||
setWinner(player1.score > player2.score ? 'P1 Wins!' : player2.score > player1.score ? 'P2 Wins!' : 'Draw!');
|
setWinner(player1.score > player2.score ? 'P1 Wins!' : player2.score > player1.score ? 'P2 Wins!' : 'Draw!');
|
||||||
@@ -421,11 +351,9 @@ const Tetris = () => {
|
|||||||
}
|
}
|
||||||
}, [player1.gameOver, player2.gameOver, player1.score, player2.score, gameMode, gameStarted]);
|
}, [player1.gameOver, player2.gameOver, player1.score, player2.score, gameMode, gameStarted]);
|
||||||
|
|
||||||
// Keyboard handler
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!gameStarted) return;
|
if (!gameStarted) return;
|
||||||
|
|
||||||
if (gameMode === '1p') {
|
if (gameMode === '1p') {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft': case 'a': e.preventDefault(); moveLeft(); break;
|
case 'ArrowLeft': case 'a': e.preventDefault(); moveLeft(); break;
|
||||||
@@ -436,20 +364,16 @@ const Tetris = () => {
|
|||||||
case 'p': e.preventDefault(); togglePause(); break;
|
case 'p': e.preventDefault(); togglePause(); break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// P1: WASD + Q for drop
|
|
||||||
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); movePlayer(1, 'rotate'); }
|
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); movePlayer(1, 'rotate'); }
|
||||||
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); movePlayer(1, 'left'); }
|
if (e.key === 'a' || e.key === 'A') { e.preventDefault(); movePlayer(1, 'left'); }
|
||||||
if (e.key === 's' || e.key === 'S') { e.preventDefault(); movePlayer(1, 'down'); }
|
if (e.key === 's' || e.key === 'S') { e.preventDefault(); movePlayer(1, 'down'); }
|
||||||
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); movePlayer(1, 'right'); }
|
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); movePlayer(1, 'right'); }
|
||||||
if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); movePlayer(1, 'drop'); }
|
if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); movePlayer(1, 'drop'); }
|
||||||
|
|
||||||
// P2: Arrows + / for drop
|
|
||||||
if (e.key === 'ArrowUp') { e.preventDefault(); movePlayer(2, 'rotate'); }
|
if (e.key === 'ArrowUp') { e.preventDefault(); movePlayer(2, 'rotate'); }
|
||||||
if (e.key === 'ArrowLeft') { e.preventDefault(); movePlayer(2, 'left'); }
|
if (e.key === 'ArrowLeft') { e.preventDefault(); movePlayer(2, 'left'); }
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); movePlayer(2, 'down'); }
|
if (e.key === 'ArrowDown') { e.preventDefault(); movePlayer(2, 'down'); }
|
||||||
if (e.key === 'ArrowRight') { e.preventDefault(); movePlayer(2, 'right'); }
|
if (e.key === 'ArrowRight') { e.preventDefault(); movePlayer(2, 'right'); }
|
||||||
if (e.key === '/') { e.preventDefault(); movePlayer(2, 'drop'); }
|
if (e.key === '/') { e.preventDefault(); movePlayer(2, 'drop'); }
|
||||||
|
|
||||||
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePause(); }
|
if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePause(); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -457,14 +381,12 @@ const Tetris = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted, gameMode, movePlayer]);
|
}, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted, gameMode, movePlayer]);
|
||||||
|
|
||||||
// 1P tick
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return;
|
if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return;
|
||||||
const interval = setInterval(moveDown, TICK_SPEED);
|
const interval = setInterval(moveDown, TICK_SPEED);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [moveDown, gameStarted, gameOver, gameComplete, isPaused, gameMode]);
|
}, [moveDown, gameStarted, gameOver, gameComplete, isPaused, gameMode]);
|
||||||
|
|
||||||
// 2P tick
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return;
|
if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -472,7 +394,7 @@ const Tetris = () => {
|
|||||||
if (!player2.gameOver) movePlayer(2, 'down');
|
if (!player2.gameOver) movePlayer(2, 'down');
|
||||||
}, TICK_SPEED);
|
}, TICK_SPEED);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [gameStarted, gameOver, isPaused, gameMode]);
|
}, [gameStarted, gameOver, isPaused, gameMode, player1.gameOver, player2.gameOver, movePlayer]);
|
||||||
|
|
||||||
const renderBoard1P = () => {
|
const renderBoard1P = () => {
|
||||||
const displayBoard = board.map(row => [...row]);
|
const displayBoard = board.map(row => [...row]);
|
||||||
@@ -512,70 +434,51 @@ const Tetris = () => {
|
|||||||
return displayBoard.map((row, y) => (
|
return displayBoard.map((row, y) => (
|
||||||
<div key={y} className="flex">
|
<div key={y} className="flex">
|
||||||
{row.map((cell, x) => (
|
{row.map((cell, x) => (
|
||||||
<div
|
<div key={`${y}-${x}`} className={`border transition-colors duration-100 ${borderColor}`}
|
||||||
key={`${y}-${x}`}
|
style={{ width: cellSize, height: cellSize, backgroundColor: cell || 'transparent', boxShadow: cell ? `0 0 5px ${cell}` : 'none' }} />
|
||||||
className={`border transition-colors duration-100 ${borderColor}`}
|
|
||||||
style={{
|
|
||||||
width: cellSize,
|
|
||||||
height: cellSize,
|
|
||||||
backgroundColor: cell || 'transparent',
|
|
||||||
boxShadow: cell ? `0 0 5px ${cell}` : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderNextPiece = (nextP: Piece) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{nextP.shape.map((row, y) => (
|
||||||
|
<div key={y} className="flex gap-1">
|
||||||
|
{row.map((val, x) => (
|
||||||
|
<div key={`${y}-${x}`} className={`w-4 h-4`} style={{ backgroundColor: val ? nextP.color : 'transparent', boxShadow: val ? `0 0 5px ${nextP.color}` : 'none' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
|
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
|
||||||
|
|
||||||
// Mode selection screen
|
|
||||||
if (!gameMode) {
|
if (!gameMode) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} className="flex flex-col h-full">
|
||||||
ref={gameRef}
|
|
||||||
tabIndex={0}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="flex flex-col h-full"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-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>
|
||||||
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Tetris</h1>
|
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Tetris</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
||||||
<p className="font-pixel text-sm text-foreground/70">Select game mode</p>
|
<p className="font-pixel text-sm text-foreground/70">Select game mode</p>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button onClick={() => startGame('1p')} className="flex flex-col items-center gap-2 p-6 border-2 border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 transition-all">
|
||||||
onClick={() => startGame('1p')}
|
|
||||||
className="flex flex-col items-center gap-2 p-6 border-2 border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 transition-all"
|
|
||||||
>
|
|
||||||
<User size={32} className="text-primary" />
|
<User size={32} className="text-primary" />
|
||||||
<span className="font-minecraft text-lg text-primary">1 Player</span>
|
<span className="font-minecraft text-lg text-primary">1 Player</span>
|
||||||
<span className="font-pixel text-xs text-foreground/60">Solo mode</span>
|
<span className="font-pixel text-xs text-foreground/60">Solo mode</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<button
|
<button onClick={() => startGame('2p')} className="flex flex-col items-center gap-2 p-6 border-2 border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 transition-all">
|
||||||
onClick={() => startGame('2p')}
|
|
||||||
className="flex flex-col items-center gap-2 p-6 border-2 border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 transition-all"
|
|
||||||
>
|
|
||||||
<Users size={32} className="text-purple-400" />
|
<Users size={32} className="text-purple-400" />
|
||||||
<span className="font-minecraft text-lg text-purple-400">2 Players</span>
|
<span className="font-minecraft text-lg text-purple-400">2 Players</span>
|
||||||
<span className="font-pixel text-xs text-foreground/60">Local versus</span>
|
<span className="font-pixel text-xs text-foreground/60">Local versus</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile && <p className="font-pixel text-xs text-foreground/40 text-center max-w-xs">2 Player mode requires a keyboard and is not available on mobile devices</p>}
|
||||||
{isMobile && (
|
|
||||||
<p className="font-pixel text-xs text-foreground/40 text-center max-w-xs">
|
|
||||||
2 Player mode requires a keyboard and is not available on mobile devices
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border border-primary/30 p-3 bg-background/30 mt-4">
|
<div className="border border-primary/30 p-3 bg-background/30 mt-4">
|
||||||
<p className="font-pixel text-xs text-foreground/60">HIGH SCORE: <span className="text-primary">{highScore.toLocaleString()}</span></p>
|
<p className="font-pixel text-xs text-foreground/60">HIGH SCORE: <span className="text-primary">{highScore.toLocaleString()}</span></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,7 +487,7 @@ const Tetris = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
return (
|
||||||
<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-50 bg-background p-4' : 'h-full'}`}>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -605,37 +508,128 @@ const Tetris = () => {
|
|||||||
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="flex flex-col gap-3 min-w-[160px]">
|
<div className="flex flex-col gap-3 min-w-[160px]">
|
||||||
{/* Next Piece Preview */}
|
|
||||||
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
||||||
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider mb-3">Next</p>
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider mb-3">Next</p>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">{renderNextPiece(nextPiece)}</div>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
{nextPiece.shape.map((row, y) => (
|
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
||||||
<div key={y} className="flex gap-1">
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider">Score</p>
|
||||||
{row.map((val, x) => (
|
<p className="font-minecraft text-2xl text-primary text-glow-strong">{score.toLocaleString()}</p>
|
||||||
<div key={`${y}-${x}`} className={`w-4 h-4 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
</div>
|
||||||
))}
|
<div className="border border-primary/30 p-3 bg-background/40">
|
||||||
</div>
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">High Score</p>
|
||||||
))}
|
<p className="font-minecraft text-lg text-primary/80">{highScore.toLocaleString()}</p>
|
||||||
</div>
|
<p className="font-pixel text-[8px] text-foreground/30 mt-1">max: 4,294,967,296</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-primary/30 p-3 bg-background/40 text-center">
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">Lines</p>
|
||||||
|
<p className="font-minecraft text-xl text-primary">{lines}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-primary/20 p-3 bg-background/30">
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider mb-2">Controls</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/60">← → / A D: Move</p>
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/60">↑ / W: Rotate</p>
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/60">↓ / S: Soft drop</p>
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/60">Space: Hard drop</p>
|
||||||
|
<p className="font-pixel text-[10px] text-foreground/60">P: Pause</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* ... keep existing code (rest of sidebar / stats / UI) */}
|
{!gameStarted || gameOver ? (
|
||||||
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
||||||
|
{gameOver ? 'RETRY' : 'START GAME'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={togglePause} className="font-minecraft text-sm py-3 px-4 border-2 border-primary/60 bg-background/50 text-primary hover:bg-primary/20 transition-all">
|
||||||
|
{isPaused ? 'RESUME' : 'PAUSE'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex gap-4 items-start">
|
||||||
{/* ... keep existing code (2P layout) */}
|
<div className="flex flex-col items-center gap-2">
|
||||||
</>
|
<p className="font-minecraft text-lg text-primary">P1 <span className="text-xs text-foreground/60">(WASD + Q)</span></p>
|
||||||
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<div><p className="font-pixel text-[10px] text-foreground/60">Score</p><p className="font-minecraft text-lg text-primary">{player1.score}</p></div>
|
||||||
|
<div><p className="font-pixel text-[10px] text-foreground/60">Lines</p><p className="font-minecraft text-lg text-primary">{player1.lines}</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-primary/30 p-2 bg-background/40">
|
||||||
|
<p className="font-pixel text-[8px] text-foreground/40">Next</p>
|
||||||
|
{renderNextPiece(player1.nextPiece)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<p className="font-minecraft text-lg text-purple-400">P2 <span className="text-xs text-foreground/60">(Arrows + /)</span></p>
|
||||||
|
<div className="border-2 border-purple-500 p-1 bg-background/80" style={{ boxShadow: '0 0 15px hsl(280 70% 50% / 0.5)' }}>{renderBoard2P(player2, 'border-purple-500/20')}</div>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<div><p className="font-pixel text-[10px] text-foreground/60">Score</p><p className="font-minecraft text-lg text-purple-400">{player2.score}</p></div>
|
||||||
|
<div><p className="font-pixel text-[10px] text-foreground/60">Lines</p><p className="font-minecraft text-lg text-purple-400">{player2.lines}</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="border border-purple-500/30 p-2 bg-background/40">
|
||||||
|
<p className="font-pixel text-[8px] text-foreground/40">Next</p>
|
||||||
|
{renderNextPiece(player2.nextPiece)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ... keep existing code (mobile controls + overlays) */}
|
{isMobile && gameMode === '1p' && (
|
||||||
|
<>
|
||||||
|
<MobileControlsSpacer heightClassName="h-36" />
|
||||||
|
<MobileControlsDock>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{score.toLocaleString()}</p></div>
|
||||||
|
<div><p className="font-pixel text-[8px] text-foreground/60">HIGH</p><p className="font-minecraft text-sm text-primary">{highScore.toLocaleString()}</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>
|
||||||
|
{gameStarted && !gameOver ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<GameTouchButton onAction={moveLeft} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel" interval={100}>←</GameTouchButton>
|
||||||
|
<GameTouchButton onAction={moveDown} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel" interval={100}>↓</GameTouchButton>
|
||||||
|
<GameTouchButton onAction={moveRight} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel" interval={100}>→</GameTouchButton>
|
||||||
|
<button onClick={rotatePiece} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">↻</button>
|
||||||
|
<button onClick={hardDrop} className="p-3 px-5 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">⬇</button>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary">{gameOver ? 'RETRY' : 'START'}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MobileControlsDock>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && (gameOver || isPaused) && gameStarted && (
|
||||||
|
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-[60]">
|
||||||
|
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
|
||||||
|
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">{gameOver ? (winner || 'GAME OVER') : 'PAUSED'}</h2>
|
||||||
|
{gameOver && gameMode === '1p' && <p className="font-pixel text-sm text-foreground/70 mb-4">Final Score: {score.toLocaleString()}</p>}
|
||||||
|
{gameOver && gameMode === '2p' && (
|
||||||
|
<div className="flex gap-8 mb-4 justify-center">
|
||||||
|
<div><p className="font-pixel text-xs text-foreground/60">P1</p><p className="font-minecraft text-lg text-primary">{player1.score}</p></div>
|
||||||
|
<div><p className="font-pixel text-xs text-foreground/60">P2</p><p className="font-minecraft text-lg text-purple-400">{player2.score}</p></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
{gameOver ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => startGame(gameMode)} className="font-minecraft text-sm py-2 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40">RETRY</button>
|
||||||
|
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-minecraft text-sm py-2 px-4 border-2 border-primary/50 text-primary/70 hover:bg-primary/20">MENU</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={togglePause} className="font-minecraft text-sm py-2 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40">RESUME</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isFullscreen ? <FullscreenPortal>{content}</FullscreenPortal> : content;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tetris;
|
export default Tetris;
|
||||||
Reference in New Issue
Block a user