diff --git a/.vscode/settings.json b/.vscode/settings.json index a6a6105..ccb373b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,9 @@ "minigames", "rgba", "Shorthair", + "Tetris", + "Tetromino", + "tetrominos", "tseslint", "Zäåö" ], diff --git a/src/App.tsx b/src/App.tsx index 0d46b6f..d904fb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { Route, HashRouter as Router, Routes } from "react-router-dom"; const Home = React.lazy(() => import("./pages/Home")); const HangmanPage = React.lazy(() => import("./pages/Hangman")); const MemoryPage = React.lazy(() => import("./pages/Memory")); +const TetrisPage = React.lazy(() => import("./pages/Tetris")); function App() { return ( @@ -14,6 +15,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Tetris/GameBoard.css b/src/components/Tetris/GameBoard.css new file mode 100644 index 0000000..e5a51d9 --- /dev/null +++ b/src/components/Tetris/GameBoard.css @@ -0,0 +1,20 @@ +.game-board { + display: grid; + grid-template-rows: repeat(20, 20px); + grid-template-columns: repeat(10, 20px); + gap: 1px; +} + +.row { + display: contents; +} + +.cell { + width: 20px; + height: 20px; + background-color: #ddd; +} + +.cell.filled { + background-color: #333; +} diff --git a/src/components/Tetris/GameBoard.tsx b/src/components/Tetris/GameBoard.tsx new file mode 100644 index 0000000..ba5b53d --- /dev/null +++ b/src/components/Tetris/GameBoard.tsx @@ -0,0 +1,24 @@ +import "./GameBoard.css"; + +interface GameBoardProps { + board: number[][]; +} + +const GameBoard: React.FC = ({ board }) => { + return ( +
+ {board.map((row, rowIndex) => ( +
+ {row.map((cell, cellIndex) => ( +
+ ))} +
+ ))} +
+ ); +}; + +export default GameBoard; diff --git a/src/components/Tetris/Tetris.tsx b/src/components/Tetris/Tetris.tsx new file mode 100644 index 0000000..4d28a0b --- /dev/null +++ b/src/components/Tetris/Tetris.tsx @@ -0,0 +1,110 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { checkCollision, createBoard, getRandomTetromino } from "./utils"; + +import GameBoard from "./GameBoard"; +import Tetromino from "./Tetromino"; + +interface Tetromino { + shape: number[][]; + color: string; +} + +// TODO: does not work +const Tetris: React.FC = () => { + const [board, setBoard] = useState(createBoard()); + const [tetromino, setTetromino] = useState(getRandomTetromino()); + const [position, setPosition] = useState({ x: 3, y: 0 }); + const [gameOver, setGameOver] = useState(false); + + const moveTetromino = useCallback( + (direction: number) => { + const newPosition = { ...position, x: position.x + direction }; + if (!checkCollision(tetromino.shape, board, newPosition)) { + setPosition(newPosition); + } + }, + [position, tetromino.shape, board], + ); + + const dropTetromino = useCallback(() => { + const newPosition = { ...position, y: position.y + 1 }; + if (!checkCollision(tetromino.shape, board, newPosition)) { + setPosition(newPosition); + } else { + // Lock the tetromino and generate a new one + const newBoard = [...board]; + tetromino.shape.forEach((row, y) => { + row.forEach((cell, x) => { + if (cell !== 0) { + newBoard[position.y + y][position.x + x] = cell; + } + }); + }); + setBoard(newBoard); + setTetromino(getRandomTetromino()); + setPosition({ x: 3, y: 0 }); + + if (checkCollision(tetromino.shape, newBoard, { x: 3, y: 0 })) { + setGameOver(true); + } + } + }, [position, tetromino.shape, board]); + + const rotateTetromino = useCallback(() => { + const rotatedTetromino = tetromino.shape + .map((_, index) => tetromino.shape.map((col) => col[index])) + .reverse(); + + const newPosition = { ...position }; + + if (!checkCollision(rotatedTetromino, board, newPosition)) { + setTetromino({ + ...tetromino, + shape: rotatedTetromino, + }); + } + }, [tetromino, position, board]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "ArrowLeft": + moveTetromino(-1); + break; + case "ArrowRight": + moveTetromino(1); + break; + case "ArrowDown": + dropTetromino(); + break; + case "ArrowUp": + rotateTetromino(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [dropTetromino, moveTetromino, rotateTetromino]); + + useEffect(() => { + const interval = setInterval(() => { + dropTetromino(); + }, 1000); + + return () => clearInterval(interval); + }, [dropTetromino]); + + return ( +
+ {gameOver ?
Game Over
: null} + + +
+ ); +}; + +export default Tetris; diff --git a/src/components/Tetris/Tetromino.tsx b/src/components/Tetris/Tetromino.tsx new file mode 100644 index 0000000..ee73dde --- /dev/null +++ b/src/components/Tetris/Tetromino.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface TetrominoProps { + shape: number[][]; + position: { x: number; y: number }; +} + +const Tetromino: React.FC = ({ shape, position }) => { + return ( +
+ {shape.map((row, rowIndex) => ( +
+ {row.map((cell, cellIndex) => ( +
+ ))} +
+ ))} +
+ ); +}; + +export default Tetromino; diff --git a/src/components/Tetris/utils.ts b/src/components/Tetris/utils.ts new file mode 100644 index 0000000..794a2be --- /dev/null +++ b/src/components/Tetris/utils.ts @@ -0,0 +1,83 @@ +export const checkCollision = ( + tetromino: number[][], + board: number[][], + position: { x: number; y: number }, +): boolean => { + for (let y = 0; y < tetromino.length; y++) { + for (let x = 0; x < tetromino[y].length; x++) { + if (tetromino[y][x] !== 0 && (!board[position.y + y] || + !board[position.y + y][position.x + x] || + board[position.y + y][position.x + x] !== 0)) { + return true; + } + + } + } + return false; +}; + +export const createBoard = (): number[][] => { + return Array.from({ length: 20 }, () => Array(10).fill(0)); +}; + +export const TETROMINOS = { + 0: { shape: [[0]], color: "0, 0, 0" }, + I: { shape: [[1, 1, 1, 1]], color: "80, 227, 230" }, + J: { + shape: [ + [1, 0, 0], + [1, 1, 1], + ], + color: "36, 95, 223", + }, + L: { + shape: [ + [0, 0, 1], + [1, 1, 1], + ], + color: "223, 173, 36", + }, + O: { + shape: [ + [1, 1], + [1, 1], + ], + color: "223, 217, 36", + }, + S: { + shape: [ + [0, 1, 1], + [1, 1, 0], + ], + color: "48, 211, 56", + }, + T: { + shape: [ + [0, 1, 0], + [1, 1, 1], + ], + color: "132, 61, 198", + }, + Z: { + shape: [ + [1, 1, 0], + [0, 1, 1], + ], + color: "227, 78, 78", + }, +}; + +export const getRandomTetromino = (): { shape: number[][]; color: string } => { + const tetrominos: Array = [ + "I", + "J", + "L", + "O", + "S", + "T", + "Z", + ]; + const randTetromino = + tetrominos[Math.floor(Math.random() * tetrominos.length)]; + return TETROMINOS[randTetromino]; +}; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 517e43a..f63e729 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; const games = [ { name: "Hangman", path: "/hangman" }, { name: "Memory", path: "/memory" }, + { name: "Tetris", path: "/tetris" }, ]; function Home() { diff --git a/src/pages/Tetris.tsx b/src/pages/Tetris.tsx new file mode 100644 index 0000000..3b001c1 --- /dev/null +++ b/src/pages/Tetris.tsx @@ -0,0 +1,7 @@ +import Tetris from "../components/Tetris/Tetris"; + +function TetrisPage() { + return ; +} + +export default TetrisPage;