diff --git a/client/app/components/GameModeSelector.tsx b/client/app/components/GameModeSelector.tsx new file mode 100644 index 0000000..f494c74 --- /dev/null +++ b/client/app/components/GameModeSelector.tsx @@ -0,0 +1,85 @@ +import { GAME_MODES, GAME_MODE_INFO, GAME_SETUP, type GameMode } from '../constants'; + +interface GameModeSelectorProps { + selected: GameMode; + onChange: (mode: GameMode) => void; + disabled?: boolean; + compact?: boolean; +} + +export function GameModeSelector({ selected, onChange, disabled, compact }: GameModeSelectorProps) { + const modes = disabled ? [selected] : Array.from(GAME_MODES); + + if (compact) { + return ( +
+ {modes.map((m) => { + const info = GAME_MODE_INFO[m]; + const isActive = selected === m; + return ( + + ); + })} +
+ ); + } + + return ( +
+ {modes.map((m) => { + const info = GAME_MODE_INFO[m]; + const setup = GAME_SETUP[m]; + const isActive = selected === m; + const pieceCount = Object.keys(setup.tetrominos).length; + + return ( + + ); + })} +
+ ); +} diff --git a/client/app/constants/index.ts b/client/app/constants/index.ts new file mode 100644 index 0000000..65afcc0 --- /dev/null +++ b/client/app/constants/index.ts @@ -0,0 +1,78 @@ +import { CLASSIC_TETROMINOS, CYBER_TETROMINOS, PENTOMINOES } from './tetrominos'; + +const STANDARD_FIRST_SPEED = 20; +const BSP_SCORES: Record = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200, 5: 5000 }; +const TICK_INTERVAL_MS = 50; + +const GAME_MODES = ['classic', 'narrow', 'pentominoes', 'cyber'] as const; +type GameMode = (typeof GAME_MODES)[number]; + +interface GAME_SETUP_ENTRY { + tetrominos: Record; + width: number; + height: number; +} + +const GAME_SETUP: Record = { + classic: { tetrominos: CLASSIC_TETROMINOS, width: 10, height: 20 }, + narrow: { tetrominos: CLASSIC_TETROMINOS, width: 6, height: 22 }, + pentominoes: { tetrominos: PENTOMINOES, width: 12, height: 22 }, + cyber: { tetrominos: CYBER_TETROMINOS, width: 8, height: 18 }, +}; + +interface GameModeInfo { + label: string; + description: string; + icon: string; + accentColor: string; + accentBg: string; + accentBorder: string; + boardLabel: string; + piecesLabel: string; +} + +const GAME_MODE_INFO: Record = { + classic: { + label: 'Classic', + description: 'The original Tetris experience with standard pieces and a 10×20 board.', + icon: '', + accentColor: 'text-blue-400', + accentBg: 'bg-blue-500/15', + accentBorder: 'border-blue-500/40', + boardLabel: '10 × 20', + piecesLabel: '7 classic tetrominoes', + }, + narrow: { + label: 'Narrow', + description: 'Classic pieces on a tight 6-wide board. Every move counts!', + icon: '', + accentColor: 'text-amber-400', + accentBg: 'bg-amber-500/15', + accentBorder: 'border-amber-500/40', + boardLabel: '6 × 22', + piecesLabel: '7 classic tetrominoes', + }, + pentominoes: { + label: 'Pentominoes', + description: 'Five-cell pieces on a wider board. A whole new level of complexity.', + icon: '', + accentColor: 'text-purple-400', + accentBg: 'bg-purple-500/15', + accentBorder: 'border-purple-500/40', + boardLabel: '12 × 22', + piecesLabel: '18 pentomino shapes', + }, + cyber: { + label: 'Cyber', + description: 'Futuristic custom shapes on a compact board. Expect the unexpected.', + icon: '', + accentColor: 'text-cyan-400', + accentBg: 'bg-cyan-500/15', + accentBorder: 'border-cyan-500/40', + boardLabel: '8 × 18', + piecesLabel: '6 cyber shapes', + }, +}; + +export { BSP_SCORES, GAME_MODES, GAME_MODE_INFO, GAME_SETUP, STANDARD_FIRST_SPEED, TICK_INTERVAL_MS }; +export type { GameMode, GameModeInfo }; diff --git a/client/app/constants/tetrominos.ts b/client/app/constants/tetrominos.ts new file mode 100644 index 0000000..3b4c5be --- /dev/null +++ b/client/app/constants/tetrominos.ts @@ -0,0 +1,523 @@ +export const CYBER_TETROMINOS: Record = { + A: [ + [ + [0, 0, 0], + [1, 0, 1], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 0, 1], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 0, 1], + [0, 1, 0], + ], + ], + B: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + [ + [0, 1, 0], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 1, 0], + ], + ], + C: [ + [ + [0, 0, 0], + [1, 1, 0], + [0, 0, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 1], + [0, 1, 0], + [0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 1], + [1, 1, 0], + [0, 0, 0], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 0, 0], + ], + [ + [1, 0, 0], + [0, 1, 0], + [0, 1, 0], + ], + ], + E: [ + [ + [0, 0, 0], + [1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + ], + ], + F: [ + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], + [ + [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + ], + ], +}; + +export const CLASSIC_TETROMINOS: Record = { + I: [ + [ + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [1, 1, 1, 1], + ], + [ + [0, 1], + [0, 1], + [0, 1], + [0, 1], + ], + ], + O: [ + [ + [1, 1], + [1, 1], + ], + ], + T: [ + [ + [0, 1, 0], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 1, 0], + ], + [ + [0, 1], + [1, 1], + [0, 1], + ], + ], + S: [ + [ + [0, 1, 1], + [1, 1, 0], + ], + [ + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [1, 1, 0], + ], + [ + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], + ], + Z: [ + [ + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 1], + [0, 1, 1], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 0], + [0, 1, 1], + ], + [ + [0, 1, 0], + [1, 1, 0], + [1, 0, 0], + ], + ], + J: [ + [ + [1, 0, 0], + [1, 1, 1], + ], + [ + [0, 1, 1], + [0, 1, 0], + [0, 1, 0], + ], + [ + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + ], + [ + [0, 1], + [0, 1], + [1, 1], + ], + ], + L: [ + [ + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 1, 0], + [0, 1, 0], + [0, 1, 1], + ], + [ + [0, 0, 0], + [1, 1, 1], + [1, 0, 0], + ], + [ + [1, 1], + [0, 1], + [0, 1], + ], + ], +}; + +export const PENTOMINOES: Record = { + A: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 1], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + B: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 1, 0], + [1, 1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 0, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + C: [ + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [1, 1, 1, 1, 0], + [0, 0, 0, 1, 0], + ], + [ + [0, 0, 1], + [0, 0, 1], + [0, 0, 1], + [0, 1, 1], + [0, 0, 0], + ], + [ + [0, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [0, 1, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + ], + D: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [1, 1, 1, 0], + ], + [ + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 1, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + E: [ + [ + [0, 0, 1], + [0, 0, 1], + [1, 1, 1], + ], + [ + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + [0, 0, 1, 1, 1], + ], + [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 1], + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 0], + ], + [ + [0, 0, 0], + [0, 0, 0], + [1, 1, 1], + [0, 0, 1], + [0, 0, 1], + ], + ], + F: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0], + [0, 1, 0], + [0, 1, 1], + [0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + G: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 1], + [0, 1, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 1, 1, 0], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ], + ], + H: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + ], + I: [ + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + ], + J: [ + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + [ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + ], + ], + K: [ + [ + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 0], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 1, 1, 1], + [0, 0, 0, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 0], + [0, 0, 1, 0], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 0], + ], + ], + L: [ + [ + [0, 0, 0, 0], + [0, 1, 0, 1], + [0, 1, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 1, 1], + [0, 0, 1, 0], + [0, 0, 1, 1], + ], + [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 1, 1, 1], + [0, 1, 0, 1], + ], + [ + [0, 0, 0], + [0, 1, 1], + [0, 0, 1], + [0, 1, 1], + ], + ], +}; diff --git a/client/app/routes/game.tsx b/client/app/routes/game.tsx index c4b01fe..a1aa388 100644 --- a/client/app/routes/game.tsx +++ b/client/app/routes/game.tsx @@ -1,16 +1,20 @@ import { useEffect, useMemo, useRef, useState, type JSX } from 'react'; import { Link } from 'react-router'; import { io } from 'socket.io-client'; -import type { Route } from './+types/game'; import { ProtectedRoute } from '../components/auth/ProtectedRoute'; +import { GameModeSelector } from '../components/GameModeSelector'; import { LoadingOverlay } from '../components/LoadingOverlay'; +import { GAME_MODE_INFO, GAME_MODES, GAME_SETUP, type GameMode } from '../constants'; import { authService } from '../services/auth'; import type { GameState, PlayerState, Socket } from '../types/socket'; +import type { Route } from './+types/game'; -const WIDTH = 10; -const HEIGHT = 20; 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)); + export function meta({}: Route.MetaArgs) { return [ { title: 'Red Tetris — Play' }, @@ -21,6 +25,7 @@ export function meta({}: Route.MetaArgs) { function GameComponent() { const [room, setRoom] = useState(''); const [playerId, setPlayerId] = useState(null); + const [selectedMode, setSelectedMode] = useState('classic'); const [state, setState] = useState(null); const [hostId, setHostId] = useState(null); const [error, setError] = useState(null); @@ -28,6 +33,10 @@ function GameComponent() { const [actionLoading, setActionLoading] = useState(false); const socketRef = useRef(null); + const setup = GAME_SETUP[selectedMode]; + const WIDTH = setup.width; + const HEIGHT = setup.height; + const computeSpectrumFromBoard = (board: number[][]): number[] => { const cols = Array.from({ length: WIDTH }, () => 0); for (let x = 0; x < WIDTH; x++) { @@ -54,15 +63,16 @@ function GameComponent() { // connection established }); - // server may emit either a raw state object or { state, hostId } historically. - s.on('state', (payload: GameState | { state: GameState; hostId?: string }) => { + // server sends a flat state object with mode & hostId + s.on('state', (payload: GameState) => { setActionLoading(false); - // normalize payload to `state` object - const incoming: GameState = 'state' in payload ? payload.state : payload; - setState(incoming); - // hostId may be attached at top-level of payload or inside incoming - const hid = 'hostId' in payload ? (payload.hostId ?? incoming?.hostId ?? null) : (incoming?.hostId ?? null); - setHostId(hid); + setState(payload); + setHostId(payload.hostId ?? null); + // sync selected mode with the room's actual mode + const m = payload.mode; + if (m && (GAME_MODES as readonly string[]).includes(m)) { + setSelectedMode(m as GameMode); + } }); s.on('joined', async (info: { roomId?: string }) => { @@ -211,8 +221,8 @@ function GameComponent() { if (!s) return; if (!s.connected) s.connect(); setActionLoading(true); - // server uses authenticated user for creation - s.emit('create'); + // server uses authenticated user for creation; include selected game mode + s.emit('create', { mode: selectedMode }); setError(null); }; @@ -366,34 +376,11 @@ function GameComponent() { }, [state, playerId]); // Next-piece shapes (rotation 0) for preview; pad to 4x4 for consistent rendering - const PIECE_SHAPES: Record = { - I: [[1, 1, 1, 1]], - O: [ - [1, 1], - [1, 1], - ], - T: [ - [0, 1, 0], - [1, 1, 1], - ], - S: [ - [0, 1, 1], - [1, 1, 0], - ], - Z: [ - [1, 1, 0], - [0, 1, 1], - ], - J: [ - [1, 0, 0], - [1, 1, 1], - ], - L: [ - [0, 0, 1], - [1, 1, 1], - ], - }; - const PIECE_COLORS: Record = { + // derive shapes/colors from selected mode's tetrominos when possible + const tetrominos = setup.tetrominos; + const pieceKeys = Object.keys(tetrominos); + + const baseClassicColors: Record = { I: '#0d9488', O: '#ca8a04', T: '#9333ea', @@ -403,15 +390,20 @@ function GameComponent() { L: '#c2410c', }; + const palette = ['#60a5fa', '#f97316', '#e11d48', '#7c3aed', '#059669', '#d946ef', '#facc15', '#06b6d4']; + + const PIECE_SHAPES: Record = {}; + const PIECE_COLORS: Record = {}; + pieceKeys.forEach((k, idx) => { + const mats = tetrominos[k]; + PIECE_SHAPES[k] = mats?.length ? mats[0] : [[1]]; + PIECE_COLORS[k] = baseClassicColors[k] ?? palette[idx % palette.length]; + }); + const PIECE_ID_COLORS: string[] = [ - '#000000', // index 0 unused - PIECE_COLORS.I, - PIECE_COLORS.O, - PIECE_COLORS.T, - PIECE_COLORS.S, - PIECE_COLORS.Z, - PIECE_COLORS.J, - PIECE_COLORS.L, + '#000000', + // numeric mapping fallback: map first few pieceKeys to indexes 1..n + ...pieceKeys.slice(0, 15).map((k) => PIECE_COLORS[k] ?? palette[0]), ]; const renderOpponentBoard = (board: number[][] | null, isAlive: boolean, cell = 4) => { @@ -500,7 +492,7 @@ function GameComponent() { className='min-h-screen bg-slate-900 p-6' style={{ pointerEvents: 'auto' }} > -
+
{/* Room Controls */} -
+

Room Settings

@@ -530,6 +522,16 @@ function GameComponent() { />
+
+ + +
+