Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/app/tetris/board.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { ReactNode } from 'react';
import { cn } from 'utils/class-names';

interface BoardProps {
board: ReactNode[][];
}

const Board = ({ board }: BoardProps) => {
return (
<div>
<table className='table'>
<tbody className='divide-y divide-solid dark:divide-neutral-700'>
{board.map((row) => (
<tr className='divide-x divide-solid dark:divide-neutral-700'>
{row.map((tile) => (
<td className={cn('h-8 w-8', tile && 'bg-red-600')}></td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};

export default Board;
7 changes: 7 additions & 0 deletions src/app/tetris/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Tetris from './tetris';

const TetrisPage = () => {
return <Tetris />;
};

export default TetrisPage;
124 changes: 124 additions & 0 deletions src/app/tetris/piece.tsx
Original file line number Diff line number Diff line change
@@ -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<Shape, ShapeTileOffsets> = {
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<Rotation, Point> = {
up: [0, 0],
right: [0, 1],
down: [1, 1],
left: [1, 0],
};

const colors: Record<Shape, string> = {
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<Rotation, (p: Point) => 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 (
<div className='relative'>
{offsets.map(([row, col]) => (
<div
className={cn('absolute h-8 w-8 border border-white', colors[shape])}
style={{
marginTop: `${(position[0] + row) * 32}px`,
marginLeft: `${(position[1] + col) * 32}px`,
}}
/>
))}
</div>
);
};

export default Piece;
91 changes: 91 additions & 0 deletions src/app/tetris/tetris.tsx
Original file line number Diff line number Diff line change
@@ -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<Shape>('I');
const [position, setPosition] = useState<Point>([0, 4]);
const [shapePool, setShapePool] = useState<Shape[]>([]);

// 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 (
<div className='m-12'>
<Piece shape={shape} rotation={rotation} position={position} />

<Board board={board} />
</div>
);
};

export default Tetris;
7 changes: 4 additions & 3 deletions src/hooks/use-key-down.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down
24 changes: 20 additions & 4 deletions src/utils/array.ts
Original file line number Diff line number Diff line change
@@ -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 = <K extends string | number | symbol, V>(
Expand Down Expand Up @@ -48,3 +49,18 @@ export const intersection = <T>(a: T[], b: T[]) => {

export const filterNone = <T>(list: (T | null | undefined)[]): T[] =>
list.flatMap((e) => (e !== null && e !== undefined ? [e] : []));

export const shuffle = <T>(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 = <T>(list: readonly T[]) => {
const copied = Array.from(list);
shuffle(copied);
return copied;
};