aeacf9e68f
- Add IMPROVEMENTS.md with detailed analysis of performance issues and bugs - Update CLAUDE.md with negative Y overflow explanation - Update README.md with socket events documentation - Enhance game-logic.js with improved comments and validation - Improve server/index.js with better documentation and edge case handling
260 lines
7.6 KiB
JavaScript
260 lines
7.6 KiB
JavaScript
// Pure game logic functions extracted for testability
|
|
|
|
const BOARD_WIDTH = 10;
|
|
const BOARD_HEIGHT = 20;
|
|
const GARBAGE_COLOR = '#666666';
|
|
|
|
// Tetromino definitions
|
|
const TETROMINOS = {
|
|
I: { shape: [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]], color: '#00ffff' },
|
|
O: { shape: [[1,1], [1,1]], color: '#ffff00' },
|
|
T: { shape: [[0,1,0], [1,1,1], [0,0,0]], color: '#800080' },
|
|
S: { shape: [[0,1,1], [1,1,0], [0,0,0]], color: '#00ff00' },
|
|
Z: { shape: [[1,1,0], [0,1,1], [0,0,0]], color: '#ff0000' },
|
|
J: { shape: [[1,0,0], [1,1,1], [0,0,0]], color: '#0000ff' },
|
|
L: { shape: [[0,0,1], [1,1,1], [0,0,0]], color: '#ffa500' }
|
|
};
|
|
|
|
const TETROMINO_KEYS = Object.keys(TETROMINOS);
|
|
|
|
/**
|
|
* Create an empty board
|
|
* @returns {number[][]} Empty 20x10 board
|
|
*/
|
|
function createEmptyBoard() {
|
|
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
|
|
}
|
|
|
|
/**
|
|
* Get a piece from its type
|
|
* @param {string} pieceType - Type of tetromino (I, O, T, S, Z, J, L)
|
|
* @returns {object} Piece object with shape and color
|
|
*/
|
|
function getPieceFromType(pieceType) {
|
|
const tetromino = TETROMINOS[pieceType];
|
|
return {
|
|
type: pieceType,
|
|
shape: JSON.parse(JSON.stringify(tetromino.shape)),
|
|
color: tetromino.color,
|
|
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2),
|
|
y: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a position is valid for a piece
|
|
* @param {object} piece - The piece to check
|
|
* @param {number} x - X position
|
|
* @param {number} y - Y position
|
|
* @param {number[][]} board - The game board
|
|
* @param {number[][]} shape - Optional custom shape (for rotation)
|
|
* @returns {boolean} True if position is valid
|
|
*
|
|
* Note: Negative Y is allowed (overflow/spawn area above visible board).
|
|
* This allows pieces to be pushed up by garbage without immediate elimination.
|
|
*/
|
|
function isValidPosition(piece, x, y, board, shape = null) {
|
|
const pieceShape = shape || piece.shape;
|
|
for (let row = 0; row < pieceShape.length; row++) {
|
|
for (let col = 0; col < pieceShape[row].length; col++) {
|
|
if (pieceShape[row][col]) {
|
|
const newX = x + col;
|
|
const newY = y + row;
|
|
|
|
// Check bounds
|
|
// Note: Only checks if piece goes BELOW board (newY >= 20)
|
|
// Negative Y is allowed - pieces can exist above visible board
|
|
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) {
|
|
return false;
|
|
}
|
|
|
|
// Check collision with locked pieces (only if on board)
|
|
// Note: Only checks collision if piece is in visible area (newY >= 0)
|
|
if (newY >= 0 && board[newY][newX]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Calculate ghost piece Y position
|
|
* @param {object} piece - The current piece
|
|
* @param {number[][]} board - The game board
|
|
* @returns {number|null} Ghost Y position or null if no piece
|
|
*/
|
|
function getGhostY(piece, board) {
|
|
if (!piece) return null;
|
|
|
|
let ghostY = piece.y;
|
|
while (isValidPosition(piece, piece.x, ghostY + 1, board)) {
|
|
ghostY++;
|
|
}
|
|
return ghostY;
|
|
}
|
|
|
|
/**
|
|
* Check if board has overflowed (blocks in top 2 rows)
|
|
* @param {number[][]} board - The game board
|
|
* @returns {boolean} True if board has overflowed
|
|
*
|
|
* Checks rows 0 AND 1 because:
|
|
* - I piece at y=0 only places blocks in row 1 (unique shape)
|
|
* - T piece at y=0 also only fills row 1
|
|
* - If we only checked row 0, these pieces would NOT trigger elimination
|
|
*
|
|
* This is called when a piece LOCKS, not when garbage is received.
|
|
* Player is eliminated only when they lock a piece that overflows.
|
|
*/
|
|
function checkBoardOverflow(board) {
|
|
for (let row = 0; row < 2; row++) {
|
|
for (let col = 0; col < BOARD_WIDTH; col++) {
|
|
if (board[row][col] !== 0) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Add garbage row to board
|
|
* @param {number[][]} board - The game board
|
|
* @param {object} currentPiece - Current falling piece (may be null)
|
|
* @returns {object} Object with new board, updated piece, and gap position
|
|
*
|
|
* When garbage is added:
|
|
* - All board rows shift up by 1 (top row removed)
|
|
* - New garbage row added at bottom with one random gap
|
|
* - Current piece y position decreases by 1 (pushed up)
|
|
*
|
|
* Note: Piece y can become negative (above visible board). This is intentional
|
|
* - Player is NOT eliminated when piece goes negative
|
|
* - Piece will drop naturally back into visible area
|
|
* - Elimination only occurs when piece LOCKS with blocks in rows 0-1
|
|
*/
|
|
function addGarbageRow(board, currentPiece) {
|
|
// Remove top row (all rows shift up)
|
|
const newBoard = board.slice(1);
|
|
|
|
// Add garbage row with random gap at bottom
|
|
const garbageRow = Array(BOARD_WIDTH).fill(GARBAGE_COLOR);
|
|
const gap = Math.floor(Math.random() * BOARD_WIDTH);
|
|
garbageRow[gap] = 0;
|
|
newBoard.push(garbageRow);
|
|
|
|
// Push current piece up by 1 row
|
|
// Note: y can become negative (above visible board) - this is intentional
|
|
// Player survives and piece will drop naturally back into view
|
|
let updatedPiece = currentPiece;
|
|
if (currentPiece) {
|
|
updatedPiece = {
|
|
...currentPiece,
|
|
y: currentPiece.y - 1
|
|
};
|
|
}
|
|
|
|
return { board: newBoard, currentPiece: updatedPiece, gap };
|
|
}
|
|
|
|
/**
|
|
* Lock a piece to the board
|
|
* @param {object} piece - The piece to lock
|
|
* @param {number[][]} board - The game board
|
|
* @returns {number[][]} New board with piece locked
|
|
*
|
|
* Only locks blocks that are within visible board bounds [0, 20).
|
|
* Blocks at negative Y (above board) are clipped and not locked.
|
|
*
|
|
* Example: I piece at y=-2 has blocks at rows -2,-1,0,1
|
|
* - Only blocks at rows 0 and 1 get locked to board
|
|
* - Blocks at rows -2 and -1 are clipped (discarded)
|
|
*/
|
|
function lockPieceToBoard(piece, board) {
|
|
const newBoard = board.map(row => [...row]);
|
|
|
|
for (let row = 0; row < piece.shape.length; row++) {
|
|
for (let col = 0; col < piece.shape[row].length; col++) {
|
|
if (piece.shape[row][col]) {
|
|
const boardY = piece.y + row;
|
|
const boardX = piece.x + col;
|
|
|
|
// Only lock blocks within visible board bounds
|
|
// Blocks above board (negative Y) are clipped
|
|
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
|
|
newBoard[boardY][boardX] = piece.color;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return newBoard;
|
|
}
|
|
|
|
/**
|
|
* Clear completed rows from board
|
|
* @param {number[][]} board - The game board
|
|
* @returns {object} Object with new board and rows cleared count
|
|
*/
|
|
function clearRows(board) {
|
|
let rowsCleared = 0;
|
|
const newBoard = board.map(row => [...row]);
|
|
|
|
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
|
|
if (newBoard[row].every(cell => cell !== 0)) {
|
|
newBoard.splice(row, 1);
|
|
newBoard.unshift(Array(BOARD_WIDTH).fill(0));
|
|
rowsCleared++;
|
|
row++; // Check same row again
|
|
}
|
|
}
|
|
|
|
return { board: newBoard, rowsCleared };
|
|
}
|
|
|
|
/**
|
|
* Generate a shuffled bag of 7 tetrominoes
|
|
* @returns {string[]} Array of 7 piece types
|
|
*/
|
|
function generatePieceBag() {
|
|
const bag = [...TETROMINO_KEYS];
|
|
for (let i = bag.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[bag[i], bag[j]] = [bag[j], bag[i]];
|
|
}
|
|
return bag;
|
|
}
|
|
|
|
/**
|
|
* Create a piece queue with multiple bags
|
|
* @param {number} bagCount - Number of bags to generate
|
|
* @returns {string[]} Array of piece types
|
|
*/
|
|
function createPieceQueue(bagCount = 14) {
|
|
const queue = [];
|
|
for (let i = 0; i < bagCount; i++) {
|
|
queue.push(...generatePieceBag());
|
|
}
|
|
return queue;
|
|
}
|
|
|
|
module.exports = {
|
|
BOARD_WIDTH,
|
|
BOARD_HEIGHT,
|
|
GARBAGE_COLOR,
|
|
TETROMINOS,
|
|
TETROMINO_KEYS,
|
|
createEmptyBoard,
|
|
getPieceFromType,
|
|
isValidPosition,
|
|
getGhostY,
|
|
checkBoardOverflow,
|
|
addGarbageRow,
|
|
lockPieceToBoard,
|
|
clearRows,
|
|
generatePieceBag,
|
|
createPieceQueue
|
|
};
|