From 039999176809d9beb055c895499e2b73aa42ead7 Mon Sep 17 00:00:00 2001 From: CodeyBoi Date: Sun, 20 Jul 2025 17:05:35 +0200 Subject: [PATCH 1/2] Created Tetris skeleton and Piece component which can render a tetris piece --- src/app/tetris/board.tsx | 14 +++++ src/app/tetris/page.tsx | 7 +++ src/app/tetris/piece.tsx | 114 ++++++++++++++++++++++++++++++++++++++ src/app/tetris/tetris.tsx | 63 +++++++++++++++++++++ src/utils/array.ts | 9 +-- 5 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 src/app/tetris/board.tsx create mode 100644 src/app/tetris/page.tsx create mode 100644 src/app/tetris/piece.tsx create mode 100644 src/app/tetris/tetris.tsx diff --git a/src/app/tetris/board.tsx b/src/app/tetris/board.tsx new file mode 100644 index 00000000..62a680ab --- /dev/null +++ b/src/app/tetris/board.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Piece, { PieceProps } from './piece'; +import { Point } from './tetris'; + +interface BoardProps { + piece: PieceProps & { position: Point }; + board: boolean[][]; +} + +const Board = ({ piece, board }: BoardProps) => { + return
{}
; +}; + +export default Board; diff --git a/src/app/tetris/page.tsx b/src/app/tetris/page.tsx new file mode 100644 index 00000000..98fef11f --- /dev/null +++ b/src/app/tetris/page.tsx @@ -0,0 +1,7 @@ +import Tetris from './tetris'; + +const TetrisPage = () => { + return ; +}; + +export default TetrisPage; diff --git a/src/app/tetris/piece.tsx b/src/app/tetris/piece.tsx new file mode 100644 index 00000000..09690e3c --- /dev/null +++ b/src/app/tetris/piece.tsx @@ -0,0 +1,114 @@ +import { cn } from 'utils/class-names'; +import { Point } from './tetris'; + +export interface PieceProps { + shape: Shape; + rotation?: Rotation; +} + +type ShapeTileOffsets = [Point, Point, Point]; + +export const SHAPES = ['I', 'J', 'L', 'O', 'S', 'Z', 'T'] as const; +export type Shape = (typeof SHAPES)[number]; + +export const ROTATIONS = ['up', 'right', 'down', 'left'] as const; +export type Rotation = (typeof ROTATIONS)[number]; + +// Defines a shape with a rotation of `top` with center at `position` by defining one tile at offset [0, 0] with three additional offsets to define the rest of the tiles. Shapes `I` and `O` have their center at [0.5, 0.5] and must be handled separately. +const tileOffsets: Record = { + I: [ + [0, -1], + [0, 1], + [0, 2], + ], + J: [ + [-1, -1], + [0, -1], + [0, 1], + ], + L: [ + [-1, 1], + [0, 1], + [0, -1], + ], + O: [ + [0, 1], + [1, 0], + [1, 1], + ], + S: [ + [0, -1], + [-1, 0], + [-1, 1], + ], + Z: [ + [0, 1], + [-1, 0], + [-1, -1], + ], + T: [ + [0, -1], + [-1, 0], + [0, 1], + ], +}; + +const translationsI: Record = { + up: [0, 0], + right: [1, 1], + down: [1, 1], + left: [0, 0], +}; + +const colors: Record = { + I: 'bg-cyan-300', + J: 'bg-blue-800', + L: 'bg-orange-500', + O: 'bg-yellow-400', + S: 'bg-lime-400', + Z: 'bg-red-600', + T: 'bg-purple-500', +}; + +const rotationFuncs: Record Point> = { + up: (p) => p, + right: ([row, col]) => [-col, row], + down: ([row, col]) => [-row, -col], + left: ([row, col]) => [col, -row], +}; + +const getTileOffsets = (shape: Shape, rotation: Rotation) => { + const offsets = tileOffsets[shape].concat([[0, 0]]); + + // Rotation has no effect on shape `O` + if (shape === 'O') { + return offsets; + } + + const rotationFunc = rotationFuncs[rotation]; + const rotated = offsets.map(rotationFunc); + + // If shape is `I`, apply translation to account for the center being at [0.5, 0.5] + if (shape === 'I') { + const [dRow, dCol] = translationsI[rotation]; + return rotated.map(([row, col]) => [row + dRow, col + dCol] as Point); + } + + return rotated; +}; + +const Piece = ({ shape, rotation = 'up' }: PieceProps) => { + const offsets = getTileOffsets(shape, rotation); + return ( +
+ {offsets.map(([row, col]) => ( +
+ ))} +
+ ); +}; + +export default Piece; diff --git a/src/app/tetris/tetris.tsx b/src/app/tetris/tetris.tsx new file mode 100644 index 00000000..0ccfc577 --- /dev/null +++ b/src/app/tetris/tetris.tsx @@ -0,0 +1,63 @@ +'use client'; + +// Styling and gameplay guidelines according to https://tetris.wiki/Tetris_Guideline + +import { range } from 'utils/array'; +import Board from './board'; +import { useState } from 'react'; +import { ROTATIONS, SHAPES } from './piece'; +import useKeyDown from 'hooks/use-key-down'; + +// Describes a point as [rowOffset, colOffset] from the top left cell as [0, 0] and the bottom right as [HEIGHT - 1, WIDTH - 1] +export type Point = [number, number]; + +const addMod = (a: number, b: number, mod: number) => { + const aAbs = a >= 0 ? a : mod + a; + const bAbs = b >= 0 ? b : mod + b; + return (aAbs + bAbs) % mod; +}; + +const Tetris = () => { + const [rotationIdx, setRotationIdx] = useState(0); + const [shapeIdx, setShapeIdx] = useState(0); + + const rotate = (diff: number) => { + setRotationIdx(addMod(rotationIdx, diff, ROTATIONS.length)); + }; + + const swapShape = (diff: number) => { + setShapeIdx(addMod(shapeIdx, diff, SHAPES.length)); + }; + + useKeyDown('ArrowUp', () => { + swapShape(1); + }); + useKeyDown('ArrowDown', () => { + swapShape(-1); + }); + useKeyDown('ArrowLeft', () => { + rotate(-1); + }); + useKeyDown('ArrowRight', () => { + rotate(1); + }); + + const rotation = ROTATIONS[rotationIdx] ?? 'up'; + const shape = SHAPES[shapeIdx] ?? 'I'; + + const board = range(22).map((_) => range(10).map((_) => false)); + return ( +
+ {' '} +
+ ); +}; + +export default Tetris; diff --git a/src/utils/array.ts b/src/utils/array.ts index 955b9cb4..b934e450 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -1,10 +1,11 @@ export const range = (startOrStop: number, stop?: number, step = 1) => { const [begin, end] = stop !== undefined ? [startOrStop, stop] : [0, startOrStop]; - return Array.from( - { length: Math.ceil((end - begin) / step) }, - (_value, index) => begin + index * step, - ); + const nums = []; + for (let i = begin; i < end; i += step) { + nums.push(i); + } + return nums; }; export const initObject = ( From e87a12ddaf69772c40c82a1d6964373953e1c346 Mon Sep 17 00:00:00 2001 From: CodeyBoi Date: Sun, 27 Jul 2025 18:01:35 +0200 Subject: [PATCH 2/2] WIP: More work on tetris game loop --- src/app/tetris/board.tsx | 26 +++++++++---- src/app/tetris/piece.tsx | 28 +++++++++----- src/app/tetris/tetris.tsx | 78 ++++++++++++++++++++++++++------------- src/hooks/use-key-down.ts | 7 ++-- src/utils/array.ts | 15 ++++++++ 5 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/app/tetris/board.tsx b/src/app/tetris/board.tsx index 62a680ab..8344d5b5 100644 --- a/src/app/tetris/board.tsx +++ b/src/app/tetris/board.tsx @@ -1,14 +1,26 @@ -import React from 'react'; -import Piece, { PieceProps } from './piece'; -import { Point } from './tetris'; +import React, { ReactNode } from 'react'; +import { cn } from 'utils/class-names'; interface BoardProps { - piece: PieceProps & { position: Point }; - board: boolean[][]; + board: ReactNode[][]; } -const Board = ({ piece, board }: BoardProps) => { - return
{}
; +const Board = ({ board }: BoardProps) => { + return ( +
+ + + {board.map((row) => ( + + {row.map((tile) => ( + + ))} + + ))} + +
+
+ ); }; export default Board; diff --git a/src/app/tetris/piece.tsx b/src/app/tetris/piece.tsx index 09690e3c..ef69cc95 100644 --- a/src/app/tetris/piece.tsx +++ b/src/app/tetris/piece.tsx @@ -3,6 +3,7 @@ import { Point } from './tetris'; export interface PieceProps { shape: Shape; + position: Point; rotation?: Rotation; } @@ -55,9 +56,9 @@ const tileOffsets: Record = { const translationsI: Record = { up: [0, 0], - right: [1, 1], + right: [0, 1], down: [1, 1], - left: [0, 0], + left: [1, 0], }; const colors: Record = { @@ -72,12 +73,12 @@ const colors: Record = { const rotationFuncs: Record Point> = { up: (p) => p, - right: ([row, col]) => [-col, row], + right: ([row, col]) => [col, -row], down: ([row, col]) => [-row, -col], - left: ([row, col]) => [col, -row], + left: ([row, col]) => [-col, row], }; -const getTileOffsets = (shape: Shape, rotation: Rotation) => { +const _getTileOffsets = (shape: Shape, rotation: Rotation) => { const offsets = tileOffsets[shape].concat([[0, 0]]); // Rotation has no effect on shape `O` @@ -97,14 +98,23 @@ const getTileOffsets = (shape: Shape, rotation: Rotation) => { return rotated; }; -const Piece = ({ shape, rotation = 'up' }: PieceProps) => { - const offsets = getTileOffsets(shape, rotation); +export const getPieceOffsets = (piece: PieceProps) => + _getTileOffsets(piece.shape, piece.rotation ?? 'up').map(([row, col]) => [ + row + piece.position[0], + col + piece.position[1], + ]); + +const Piece = ({ shape, rotation = 'up', position }: PieceProps) => { + const offsets = _getTileOffsets(shape, rotation); return (
{offsets.map(([row, col]) => (
))}
diff --git a/src/app/tetris/tetris.tsx b/src/app/tetris/tetris.tsx index 0ccfc577..f4ba2838 100644 --- a/src/app/tetris/tetris.tsx +++ b/src/app/tetris/tetris.tsx @@ -2,60 +2,88 @@ // Styling and gameplay guidelines according to https://tetris.wiki/Tetris_Guideline -import { range } from 'utils/array'; +import { range, toShuffled } from 'utils/array'; import Board from './board'; -import { useState } from 'react'; -import { ROTATIONS, SHAPES } from './piece'; +import { useEffect, useState } from 'react'; +import Piece, { ROTATIONS, Shape, SHAPES } from './piece'; import useKeyDown from 'hooks/use-key-down'; // Describes a point as [rowOffset, colOffset] from the top left cell as [0, 0] and the bottom right as [HEIGHT - 1, WIDTH - 1] export type Point = [number, number]; -const addMod = (a: number, b: number, mod: number) => { +const add = (a: Point, b: Point): Point => [a[0] + b[0], a[1] + b[1]]; + +const modAdd = (a: number, b: number, mod: number) => { const aAbs = a >= 0 ? a : mod + a; const bAbs = b >= 0 ? b : mod + b; return (aAbs + bAbs) % mod; }; +const getNewShapePool = () => toShuffled(SHAPES); + const Tetris = () => { const [rotationIdx, setRotationIdx] = useState(0); - const [shapeIdx, setShapeIdx] = useState(0); + const [shape, setShape] = useState('I'); + const [position, setPosition] = useState([0, 4]); + const [shapePool, setShapePool] = useState([]); + + // Initialize game values + useEffect(() => { + const initShapePool = getNewShapePool(); + const initShape = initShapePool.pop(); + if (!initShape) { + throw new Error('Failed initializing shape pool'); + } + setShape(initShape); + setShapePool(initShapePool); + }, []); + + // Refresh shape pool if its ever empty + useEffect(() => { + if (shapePool.length === 0) { + setShapePool(toShuffled(SHAPES)); + } + }, [shapePool]); + + const move = (p: Point) => + { setPosition([position[0] + p[0], position[1] + p[1]]); }; const rotate = (diff: number) => { - setRotationIdx(addMod(rotationIdx, diff, ROTATIONS.length)); + setRotationIdx(modAdd(rotationIdx, diff, ROTATIONS.length)); }; - const swapShape = (diff: number) => { - setShapeIdx(addMod(shapeIdx, diff, SHAPES.length)); + const popShapePool = () => { + const shape = shapePool[-1]; + setShapePool(shapePool.slice(0, -1)); + return shape as Shape; }; - useKeyDown('ArrowUp', () => { - swapShape(1); - }); - useKeyDown('ArrowDown', () => { - swapShape(-1); - }); useKeyDown('ArrowLeft', () => { - rotate(-1); + move([0, -1]); }); useKeyDown('ArrowRight', () => { + move([0, 1]); + }); + useKeyDown('ArrowDown', () => { + move([1, 0]); + }); + useKeyDown(['ArrowUp', 'X'], () => { rotate(1); }); + useKeyDown('Z', () => { + rotate(-1); + }); + + const updateGameState = () => {}; const rotation = ROTATIONS[rotationIdx] ?? 'up'; - const shape = SHAPES[shapeIdx] ?? 'I'; - const board = range(22).map((_) => range(10).map((_) => false)); + const board = range(22).map((_) => range(10).map((_) => true)); return (
- {' '} + + +
); }; diff --git a/src/hooks/use-key-down.ts b/src/hooks/use-key-down.ts index cdc2bd46..90012b9f 100644 --- a/src/hooks/use-key-down.ts +++ b/src/hooks/use-key-down.ts @@ -2,13 +2,14 @@ import { useEffect } from 'react'; import { getKeyCode } from 'utils/key'; const useKeyDown = ( - key: string, + key: string | string[], callback: (() => void) | ((arg0: KeyboardEvent) => void), ) => { + const keys = (!Array.isArray(key) ? [key] : key).map((k) => k.toLowerCase()); useEffect(() => { const handler = (event: KeyboardEvent) => { - const keyCode = getKeyCode(event); - if (keyCode.toLowerCase() === key.toLowerCase()) { + const keyCode = getKeyCode(event).toLowerCase(); + if (keys.includes(keyCode)) { callback(event); } }; diff --git a/src/utils/array.ts b/src/utils/array.ts index b934e450..28bc5431 100644 --- a/src/utils/array.ts +++ b/src/utils/array.ts @@ -49,3 +49,18 @@ export const intersection = (a: T[], b: T[]) => { export const filterNone = (list: (T | null | undefined)[]): T[] => list.flatMap((e) => (e !== null && e !== undefined ? [e] : [])); + +export const shuffle = (list: T[]) => { + for (let i = list.length - 1; i > 0; i--) { + const idx = Math.floor(Math.random() * (i + 1)); + const temp = list[i]; + list[i] = list[idx] as T; + list[idx] = temp as T; + } +}; + +export const toShuffled = (list: readonly T[]) => { + const copied = Array.from(list); + shuffle(copied); + return copied; +};