diff --git a/src/app/tetris/board.tsx b/src/app/tetris/board.tsx
new file mode 100644
index 00000000..8344d5b5
--- /dev/null
+++ b/src/app/tetris/board.tsx
@@ -0,0 +1,26 @@
+import React, { ReactNode } from 'react';
+import { cn } from 'utils/class-names';
+
+interface BoardProps {
+ board: ReactNode[][];
+}
+
+const Board = ({ board }: BoardProps) => {
+ return (
+
+
+
+ {board.map((row) => (
+
+ {row.map((tile) => (
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+};
+
+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..ef69cc95
--- /dev/null
+++ b/src/app/tetris/piece.tsx
@@ -0,0 +1,124 @@
+import { cn } from 'utils/class-names';
+import { Point } from './tetris';
+
+export interface PieceProps {
+ shape: Shape;
+ position: Point;
+ 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: [0, 1],
+ down: [1, 1],
+ left: [1, 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;
+};
+
+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]) => (
+
+ ))}
+
+ );
+};
+
+export default Piece;
diff --git a/src/app/tetris/tetris.tsx b/src/app/tetris/tetris.tsx
new file mode 100644
index 00000000..f4ba2838
--- /dev/null
+++ b/src/app/tetris/tetris.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+// Styling and gameplay guidelines according to https://tetris.wiki/Tetris_Guideline
+
+import { range, toShuffled } from 'utils/array';
+import Board from './board';
+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 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 [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(modAdd(rotationIdx, diff, ROTATIONS.length));
+ };
+
+ const popShapePool = () => {
+ const shape = shapePool[-1];
+ setShapePool(shapePool.slice(0, -1));
+ return shape as Shape;
+ };
+
+ useKeyDown('ArrowLeft', () => {
+ 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 board = range(22).map((_) => range(10).map((_) => true));
+ return (
+
+ );
+};
+
+export default Tetris;
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 955b9cb4..28bc5431 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 = (
@@ -48,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;
+};