-
Notifications
You must be signed in to change notification settings - Fork 1
Implement Tetris game with board and tetromino logic #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,9 @@ | |
| "minigames", | ||
| "rgba", | ||
| "Shorthair", | ||
| "Tetris", | ||
| "Tetromino", | ||
| "tetrominos", | ||
| "tseslint", | ||
| "Zäåö" | ||
| ], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import "./GameBoard.css"; | ||
|
|
||
| interface GameBoardProps { | ||
| board: number[][]; | ||
| } | ||
|
|
||
| const GameBoard: React.FC<GameBoardProps> = ({ board }) => { | ||
| return ( | ||
| <div className="game-board"> | ||
| {board.map((row, rowIndex) => ( | ||
| <div key={rowIndex} className="row"> | ||
| {row.map((cell, cellIndex) => ( | ||
| <div | ||
| key={cellIndex} | ||
| className={`cell ${cell ? "filled" : ""}`} | ||
|
Comment on lines
+14
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using Recommended Solution: Use a more stable identifier for the key, such as a combination of |
||
| ></div> | ||
| ))} | ||
| </div> | ||
| ))} | ||
|
Comment on lines
+10
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use of nested Recommended Solution: Consider using a memoization technique or React's |
||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default GameBoard; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (complexity): Consider extracting the game logic into a custom hook to separate it from the rendering logic. The component's complexity can be reduced by:
Example implementation: // useTetrisGame.ts
export function useTetrisGame() {
const [board, setBoard] = useState(createBoard());
const [tetromino, setTetromino] = useState<Tetromino>(getRandomTetromino());
const [position, setPosition] = useState({ x: 3, y: 0 });
const [gameOver, setGameOver] = useState(false);
function moveTetromino(direction: number) {
const newPosition = { ...position, x: position.x + direction };
if (!checkCollision(tetromino.shape, board, newPosition)) {
setPosition(newPosition);
}
}
function dropTetromino() {
const newPosition = { ...position, y: position.y + 1 };
if (!checkCollision(tetromino.shape, board, newPosition)) {
setPosition(newPosition);
return;
}
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);
}
}
return {
board,
tetromino,
position,
gameOver,
moveTetromino,
dropTetromino,
// ... other methods
};
}
// Tetris.tsx
const Tetris: React.FC = () => {
const game = useTetrisGame();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowLeft": game.moveTetromino(-1); break;
case "ArrowRight": game.moveTetromino(1); break;
case "ArrowDown": game.dropTetromino(); break;
case "ArrowUp": game.rotateTetromino(); break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [game]);
return (
<div className="game">
{game.gameOver ? <div className="game-over">Game Over</div> : null}
<GameBoard board={game.board} />
<Tetromino shape={game.tetromino.shape} position={game.position} />
</div>
);
};This approach:
|
||
| const Tetris: React.FC = () => { | ||
| const [board, setBoard] = useState(createBoard()); | ||
| const [tetromino, setTetromino] = useState<Tetromino>(getRandomTetromino()); | ||
| const [position, setPosition] = useState({ x: 3, y: 0 }); | ||
| const [gameOver, setGameOver] = useState(false); | ||
|
|
||
| const moveTetromino = useCallback( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Add bounds checking to prevent pieces from moving off the board edges The current collision detection only checks for collisions with other pieces and the bottom. You should also check if position.x + tetromino width is within the board bounds. |
||
| (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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: Implement row clearing mechanics when a row is completely filled After locking a piece, check for and remove any completed rows, then shift all rows above downward. |
||
| const newBoard = [...board]; | ||
| tetromino.shape.forEach((row, y) => { | ||
| row.forEach((cell, x) => { | ||
| if (cell !== 0) { | ||
| newBoard[position.y + y][position.x + x] = cell; | ||
| } | ||
| }); | ||
| }); | ||
|
Comment on lines
+35
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The operation Recommended Solution: const newBoard = board.map(innerArray => [...innerArray]); |
||
| setBoard(newBoard); | ||
| setTetromino(getRandomTetromino()); | ||
| setPosition({ x: 3, y: 0 }); | ||
|
|
||
| if (checkCollision(tetromino.shape, newBoard, { x: 3, y: 0 })) { | ||
| setGameOver(true); | ||
| } | ||
|
Comment on lines
+47
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The game over check is performed after setting a new tetromino, which might not be the most efficient or safest point to perform this check. This could potentially lead to a scenario where the game over state is set but the board state is not correctly updated to reflect this, leading to inconsistencies in the UI and game logic. Recommended Solution: if (checkCollision(tetromino.shape, board, { x: 3, y: 0 })) {
setGameOver(true);
} else {
setTetromino(getRandomTetromino());
setPosition({ x: 3, y: 0 });
} |
||
| } | ||
| }, [position, tetromino.shape, board]); | ||
|
|
||
| const rotateTetromino = useCallback(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Implement wall kick behavior when rotating pieces near walls When a rotation would cause a collision, try shifting the piece left or right to make it fit - this is standard Tetris behavior known as wall kicks. |
||
| 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 ( | ||
| <div className="game"> | ||
| {gameOver ? <div className="game-over">Game Over</div> : null} | ||
| <GameBoard board={board} /> | ||
| <Tetromino shape={tetromino.shape} position={position} /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Tetris; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import React from "react"; | ||
|
|
||
| interface TetrominoProps { | ||
| shape: number[][]; | ||
| position: { x: number; y: number }; | ||
| } | ||
|
|
||
| const Tetromino: React.FC<TetrominoProps> = ({ shape, position }) => { | ||
| return ( | ||
| <div | ||
| style={{ | ||
| position: "absolute", | ||
| top: position.y * 20, | ||
| left: position.x * 20, | ||
|
Comment on lines
+13
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The positioning of the Tetromino uses hardcoded multipliers (20) for the Recommended Solution: const scale = props.scale || 20; // default to 20 if not provided
const style = useMemo(() => ({
position: 'absolute',
top: position.y * scale,
left: position.x * scale
}), [position.x, position.y, scale]); |
||
| }} | ||
|
Comment on lines
+11
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using inline styles in React components can lead to performance issues because the style object is recreated on every render. This can cause unnecessary re-renders of the DOM elements. Consider using Recommended Solution: const style = useMemo(() => ({
position: 'absolute',
top: position.y * 20,
left: position.x * 20
}), [position.x, position.y]);Then use this |
||
| > | ||
| {shape.map((row, rowIndex) => ( | ||
| <div key={rowIndex}> | ||
| {row.map((cell, cellIndex) => ( | ||
| <div | ||
| key={cellIndex} | ||
| className={`cell ${cell ? "filled" : ""}`} | ||
| ></div> | ||
| ))} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Tetromino; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<keyof typeof TETROMINOS> = [ | ||
| "I", | ||
| "J", | ||
| "L", | ||
| "O", | ||
| "S", | ||
| "T", | ||
| "Z", | ||
| ]; | ||
| const randTetromino = | ||
| tetrominos[Math.floor(Math.random() * tetrominos.length)]; | ||
| return TETROMINOS[randTetromino]; | ||
|
Comment on lines
+81
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Note: No change is necessary for the current context unless the application requirements specify higher security for random number generation. |
||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import Tetris from "../components/Tetris/Tetris"; | ||
|
|
||
| function TetrisPage() { | ||
| return <Tetris />; | ||
| } | ||
|
|
||
| export default TetrisPage; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The grid dimensions are defined with fixed pixel values (
20px), which limits the responsiveness and scalability of the game board. Consider using relative units likevw,vh, or percentages to make the grid responsive and adaptable to different screen sizes.For example, you could modify the grid definition to use viewport units: