Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,56 @@ jobs:

- name: Build packages
run: pnpm run build

test:
runs-on: ubuntu-latest
needs: lint-and-format

services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: rootpw
MYSQL_DATABASE: red_tetris_test
MYSQL_USER: app
MYSQL_PASSWORD: app_pw_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-interval=10s
--health-timeout=5s
--health-retries=5

env:
NODE_ENV: test
DB_HOST: 127.0.0.1
DB_USER: app
DB_PASSWORD_TEST: app_pw_test
DB_NAME_TEST: red_tetris_test
DB_PORT: 3306
JWT_SECRET: ci_test_secret_key_not_for_production

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.14.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install

- name: Run server tests
run: pnpm --filter=server test

- name: Run client tests with coverage
run: pnpm --filter=client coverage
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ package-lock.json
.pnpm-store

# testing
/coverage
coverage

# next.js
/.next/
Expand Down
134 changes: 134 additions & 0 deletions client/app/components/game/GameBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useMemo, type JSX } from 'react';

interface GameBoardProps {
board: number[][] | undefined;
idColors: string[];
width: number;
height: number;
cellSize: number;
maxWidth: number;
maxHeight: number;
score: number;
}

/**
* Renders the main Tetris game board with all cells, inside a fixed-size container.
*/
export function GameBoard({ board, idColors, width, height, cellSize, maxWidth, maxHeight, score }: GameBoardProps) {
const cells = useMemo(() => {
const result: JSX.Element[] = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
result.push(renderCell(x, y, board, idColors, cellSize));
}
}
return result;
}, [board, idColors, width, height, cellSize]);

return (
<div className='flex flex-col items-center'>
<div
className='bg-gray-900 rounded-lg p-3 border border-gray-600 flex items-center justify-center'
style={{ width: maxWidth * cellSize + 24, height: maxHeight * cellSize + 24 }}
>
<div
style={{
width: width * cellSize,
display: 'grid',
gridTemplateColumns: `repeat(${width}, ${cellSize}px)`,
gap: 0,
}}
>
{cells}
</div>
</div>

<div className='mt-4 bg-gray-800 rounded-lg p-3 border border-gray-600'>
<div className='text-center text-white'>
<span className='text-lg font-bold text-white'>{score}</span>
<div className='text-sm text-gray-400'>Score</div>
</div>
</div>
</div>
);
}

function renderCell(
x: number,
y: number,
board: number[][] | undefined,
idColors: string[],
cellSize: number
): JSX.Element {
const val = board?.[y]?.[x] ?? 0;

if (val === 0) {
return (
<div
key={`${x}-${y}`}
style={{
width: cellSize,
height: cellSize,
boxSizing: 'border-box',
outline: '1px solid rgba(255,255,255,0.12)',
}}
/>
);
}

if (val === -2) {
return (
<div
key={`${x}-${y}`}
style={{
width: cellSize,
height: cellSize,
boxSizing: 'border-box',
borderRadius: 2,
background: 'rgba(255,255,255,0.12)',
borderTop: '1px solid rgba(255,255,255,0.15)',
borderLeft: '1px solid rgba(255,255,255,0.1)',
borderBottom: '1px solid rgba(0,0,0,0.3)',
borderRight: '1px solid rgba(0,0,0,0.2)',
}}
/>
);
}

if (val === -1) {
return (
<div
key={`${x}-${y}`}
style={{
width: cellSize,
height: cellSize,
boxSizing: 'border-box',
borderRadius: 2,
background: '#374151',
borderTop: '1px solid rgba(255,255,255,0.08)',
borderLeft: '1px solid rgba(255,255,255,0.05)',
borderBottom: '1px solid rgba(0,0,0,0.3)',
borderRight: '1px solid rgba(0,0,0,0.2)',
}}
/>
);
}

const color = idColors[val] ?? '#999';
return (
<div
key={`${x}-${y}`}
style={{
width: cellSize,
height: cellSize,
boxSizing: 'border-box',
borderRadius: 2,
background: color,
borderTop: '1px solid rgba(255,255,255,0.15)',
borderLeft: '1px solid rgba(255,255,255,0.1)',
borderBottom: '1px solid rgba(0,0,0,0.3)',
borderRight: '1px solid rgba(0,0,0,0.2)',
}}
/>
);
}
167 changes: 167 additions & 0 deletions client/app/components/game/GamePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { LoadingOverlay } from '../LoadingOverlay';
import { GAME_MODE_INFO, GAME_SETUP } from '../../constants';
import { useGameSocket } from '../../hooks/useGameSocket';
import { useKeyboardControls } from '../../hooks/useKeyboardControls';
import { buildPieceMeta } from '../../utils/pieceHelpers';
import { GameBoard } from './GameBoard';
import { OpponentCard } from './OpponentCard';
import { PiecePreview } from './PiecePreview';
import { RoomControls } from './RoomControls';

const CELL = 28;

// Fixed dimensions based on the widest/tallest mode so layout doesn't shift when switching modes
const MAX_WIDTH = Math.max(...Object.values(GAME_SETUP).map((s) => s.width));
const MAX_HEIGHT = Math.max(...Object.values(GAME_SETUP).map((s) => s.height));

/**
* Main game page component — composes all game sub-components and wires up hooks.
*/
export function GamePage() {
const game = useGameSocket();
useKeyboardControls(game.socketRef, game.room, game.playerId);

const setup = GAME_SETUP[game.selectedMode];
const WIDTH = setup.width;
const HEIGHT = setup.height;

const pieceMeta = useMemo(() => buildPieceMeta(game.selectedMode), [game.selectedMode]);

const modeInfo = GAME_MODE_INFO[game.selectedMode];

const roomControlProps = {
room: game.room,
setRoom: game.setRoom,
selectedMode: game.selectedMode,
setSelectedMode: game.setSelectedMode,
playerId: game.playerId,
hostId: game.hostId,
state: game.state,
error: game.error,
message: game.message,
hostName: game.hostName,
create: game.create,
join: game.join,
start: game.start,
leave: game.leave,
restart: game.restart,
};

return (
<main
className='min-h-screen bg-slate-900 p-6'
style={{ pointerEvents: 'auto' }}
>
<div className='container mx-auto max-w-[1600px]'>
<header className='flex items-center justify-center mb-8 relative'>
<Link
to='/'
className='absolute left-0 z-10 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-600 text-gray-300 hover:text-white font-medium rounded-lg transition-colors text-sm flex items-center gap-2'
>
Back
</Link>
<div className='text-center'>
<h1 className='text-4xl font-bold text-red-500 mb-2'>RED TETRIS</h1>
<p className='text-gray-400'>Play Mode</p>
</div>
</header>

{/* Mobile Room Controls */}
<RoomControls
{...roomControlProps}
compact
/>

{/* Main Game Layout */}
<div className='flex flex-wrap xl:flex-nowrap gap-6 justify-center items-start min-h-[600px]'>
{/* Left Side: Room Controls (Desktop) */}
<RoomControls {...roomControlProps} />

{/* Center: Game Area */}
<div className='flex flex-wrap sm:flex-nowrap items-start gap-4 sm:gap-6 justify-center'>
{/* Hold Piece */}
<div className='bg-gray-900 rounded-xl p-4 border border-gray-600'>
<h3 className='text-sm font-semibold text-white mb-3 text-center'>Hold</h3>
<div
className='bg-gray-900 rounded-lg p-3 border border-gray-600 flex justify-center items-center'
style={{ width: 4 * CELL + 24, height: 4 * CELL + 24 }}
>
<PiecePreview
type={game.myView?.holdPiece ?? null}
shapes={pieceMeta.shapes}
colors={pieceMeta.colors}
cellSize={CELL}
/>
</div>
</div>

{/* Main Game Board */}
<div className='bg-gray-900 rounded-xl p-6 border border-gray-600'>
<h3 className='text-xl font-semibold text-white mb-4 text-center'>Your Board</h3>
<div className='flex items-center justify-center gap-2 mb-3'>
<span className='text-base'>{modeInfo.icon}</span>
<span className={`text-sm font-medium ${modeInfo.accentColor}`}>{modeInfo.label}</span>
<span className='text-[10px] text-gray-500'>({modeInfo.boardLabel})</span>
</div>

<GameBoard
board={game.myView?.board}
idColors={pieceMeta.idColors}
width={WIDTH}
height={HEIGHT}
cellSize={CELL}
maxWidth={MAX_WIDTH}
maxHeight={MAX_HEIGHT}
score={game.myView?.score ?? 0}
/>
</div>

{/* Next Piece */}
<div className='bg-gray-900 rounded-xl p-4 border border-gray-600'>
<h3 className='text-sm font-semibold text-white mb-3 text-center'>Next</h3>
<div
className='bg-gray-900 rounded-lg p-3 border border-gray-600 flex justify-center items-center'
style={{ width: 4 * CELL + 24, height: 4 * CELL + 24 }}
>
<PiecePreview
type={game.myView?.nextPiece ?? null}
shapes={pieceMeta.shapes}
colors={pieceMeta.colors}
cellSize={CELL}
/>
</div>
</div>
</div>

{/* Right Side: Opponents */}
{game.opponents.length > 0 && (
<div className='w-full sm:w-72 xl:w-80 shrink-0 space-y-4'>
<div className='bg-gray-900 rounded-xl p-4 border border-gray-600'>
<h3 className='text-lg font-semibold text-white mb-4 text-center'>Opponents</h3>
<div className='space-y-3'>
{game.opponents.map((op) => (
<OpponentCard
key={op.id}
id={op.id}
name={op.name}
board={op.board}
isAlive={op.isAlive}
score={op.score}
isHost={op.isHost}
idColors={pieceMeta.idColors}
width={WIDTH}
height={HEIGHT}
/>
))}
</div>
</div>
</div>
)}
</div>
</div>
{game.actionLoading && <LoadingOverlay />}
</main>
);
}
Loading
Loading