ada9ce73fa
Previously, players were eliminated when currentPiece.y <= 0, which was too aggressive. Pieces like I, T, S, Z, J, L spawn at y=0 but have empty top rows, so their actual blocks are at y >= 1 and visible on the board. Now we check if any part of the locked piece is actually above y=0 (not visible) before eliminating the player.
395 lines
11 KiB
JavaScript
395 lines
11 KiB
JavaScript
// Core Tetris Game Logic
|
|
|
|
const BOARD_WIDTH = 10;
|
|
const BOARD_HEIGHT = 20;
|
|
const CELL_SIZE = 24;
|
|
|
|
// Tetromino definitions with colors
|
|
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);
|
|
const GARbage_COLOR = '#666666';
|
|
|
|
class TetrisGame {
|
|
constructor(playerId) {
|
|
this.playerId = playerId;
|
|
this.board = this.createEmptyBoard();
|
|
this.currentPiece = null;
|
|
this.nextPiece = null;
|
|
this.holdPiece = null;
|
|
this.canHold = true;
|
|
this.score = 0;
|
|
this.lines = 0;
|
|
this.level = 1;
|
|
this.gameOver = false;
|
|
this.eliminated = false;
|
|
|
|
// Game speed (milliseconds per drop)
|
|
this.dropInterval = 1000;
|
|
this.lastDrop = 0;
|
|
|
|
// Input lock for delay hits
|
|
this.inputLocked = false;
|
|
this.lockUntil = 0;
|
|
}
|
|
|
|
createEmptyBoard() {
|
|
return Array(BOARD_HEIGHT).fill(null).map(() => Array(BOARD_WIDTH).fill(0));
|
|
}
|
|
|
|
initialize() {
|
|
this.board = this.createEmptyBoard();
|
|
this.score = 0;
|
|
this.lines = 0;
|
|
this.level = 1;
|
|
this.gameOver = false;
|
|
this.eliminated = false;
|
|
this.dropInterval = 1000;
|
|
this.holdPiece = null;
|
|
this.canHold = true;
|
|
this.currentPiece = this.spawnPiece();
|
|
this.nextPiece = this.getRandomPiece();
|
|
|
|
if (!this.currentPiece) {
|
|
this.gameOver = true;
|
|
}
|
|
}
|
|
|
|
getRandomPiece() {
|
|
const key = TETROMINO_KEYS[Math.floor(Math.random() * TETROMINO_KEYS.length)];
|
|
const tetromino = TETROMINOS[key];
|
|
return {
|
|
type: key,
|
|
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
|
|
};
|
|
}
|
|
|
|
spawnPiece() {
|
|
const piece = this.nextPiece;
|
|
this.nextPiece = this.getRandomPiece();
|
|
|
|
// Check if spawn position is valid
|
|
if (!this.isValidPosition(piece.x, piece.y, piece.shape)) {
|
|
return null;
|
|
}
|
|
|
|
return piece;
|
|
}
|
|
|
|
isValidPosition(x, y, shape) {
|
|
for (let row = 0; row < shape.length; row++) {
|
|
for (let col = 0; col < shape[row].length; col++) {
|
|
if (shape[row][col]) {
|
|
const newX = x + col;
|
|
const newY = y + row;
|
|
|
|
// Check bounds
|
|
if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) {
|
|
return false;
|
|
}
|
|
|
|
// Check collision with locked pieces (only if on board)
|
|
if (newY >= 0 && this.board[newY][newX]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
move(direction) {
|
|
if (this.gameOver || this.inputLocked) return false;
|
|
|
|
const newX = this.currentPiece.x + (direction === 'left' ? -1 : 1);
|
|
|
|
if (this.isValidPosition(newX, this.currentPiece.y, this.currentPiece.shape)) {
|
|
this.currentPiece.x = newX;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
rotate() {
|
|
if (this.gameOver || this.inputLocked) return false;
|
|
|
|
const originalShape = this.currentPiece.shape;
|
|
const rows = originalShape.length;
|
|
const cols = originalShape[0].length;
|
|
|
|
// Rotate 90 degrees clockwise
|
|
const rotated = Array(cols).fill(null).map(() => Array(rows).fill(0));
|
|
for (let row = 0; row < rows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
rotated[col][rows - 1 - row] = originalShape[row][col];
|
|
}
|
|
}
|
|
|
|
// Try rotation with wall kicks
|
|
const kicks = [0, -1, 1, -2, 2];
|
|
for (const kick of kicks) {
|
|
if (this.isValidPosition(this.currentPiece.x + kick, this.currentPiece.y, rotated)) {
|
|
this.currentPiece.shape = rotated;
|
|
this.currentPiece.x += kick;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
drop() {
|
|
if (this.gameOver || this.inputLocked) return false;
|
|
|
|
const newY = this.currentPiece.y + 1;
|
|
|
|
if (this.isValidPosition(this.currentPiece.x, newY, this.currentPiece.shape)) {
|
|
this.currentPiece.y = newY;
|
|
return true;
|
|
}
|
|
|
|
// Lock the piece
|
|
return this.lockPiece();
|
|
}
|
|
|
|
hardDrop() {
|
|
if (this.gameOver || this.inputLocked) return 0;
|
|
|
|
let dropped = 0;
|
|
while (this.isValidPosition(this.currentPiece.x, this.currentPiece.y + 1, this.currentPiece.shape)) {
|
|
this.currentPiece.y++;
|
|
dropped++;
|
|
}
|
|
|
|
this.lockPiece();
|
|
return dropped;
|
|
}
|
|
|
|
hold() {
|
|
if (this.gameOver || this.inputLocked || !this.canHold) return false;
|
|
|
|
if (this.holdPiece === null) {
|
|
// First hold - store current piece and spawn next
|
|
this.holdPiece = {
|
|
type: this.currentPiece.type,
|
|
shape: JSON.parse(JSON.stringify(this.currentPiece.shape)),
|
|
color: this.currentPiece.color
|
|
};
|
|
this.currentPiece = this.nextPiece;
|
|
this.nextPiece = this.getRandomPiece();
|
|
} else {
|
|
// Swap with held piece
|
|
const temp = {
|
|
type: this.currentPiece.type,
|
|
shape: JSON.parse(JSON.stringify(this.currentPiece.shape)),
|
|
color: this.currentPiece.color
|
|
};
|
|
this.currentPiece = {
|
|
...this.holdPiece,
|
|
shape: JSON.parse(JSON.stringify(this.holdPiece.shape)),
|
|
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(this.holdPiece.shape[0].length / 2),
|
|
y: 0
|
|
};
|
|
this.holdPiece = temp;
|
|
}
|
|
|
|
this.canHold = false;
|
|
return true;
|
|
}
|
|
|
|
lockPiece() {
|
|
if (!this.currentPiece) return false;
|
|
|
|
// Lock piece into board
|
|
for (let row = 0; row < this.currentPiece.shape.length; row++) {
|
|
for (let col = 0; col < this.currentPiece.shape[row].length; col++) {
|
|
if (this.currentPiece.shape[row][col]) {
|
|
const boardY = this.currentPiece.y + row;
|
|
const boardX = this.currentPiece.x + col;
|
|
|
|
if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) {
|
|
this.board[boardY][boardX] = this.currentPiece.color;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for game over (piece locked above visible area)
|
|
// Only eliminate if any part of the piece is above y=0 (not visible)
|
|
let pieceAboveBoard = false;
|
|
for (let row = 0; row < this.currentPiece.shape.length; row++) {
|
|
for (let col = 0; col < this.currentPiece.shape[row].length; col++) {
|
|
if (this.currentPiece.shape[row][col]) {
|
|
const boardY = this.currentPiece.y + row;
|
|
if (boardY < 0) {
|
|
pieceAboveBoard = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (pieceAboveBoard) break;
|
|
}
|
|
if (pieceAboveBoard) {
|
|
this.gameOver = true;
|
|
this.eliminated = true;
|
|
}
|
|
|
|
// Clear completed rows
|
|
const cleared = this.clearRows();
|
|
|
|
// Spawn new piece
|
|
this.currentPiece = this.spawnPiece();
|
|
if (!this.currentPiece) {
|
|
this.gameOver = true;
|
|
this.eliminated = true;
|
|
}
|
|
|
|
// Reset canHold for the new piece
|
|
this.canHold = true;
|
|
|
|
return cleared;
|
|
}
|
|
|
|
clearRows() {
|
|
let rowsCleared = 0;
|
|
|
|
for (let row = BOARD_HEIGHT - 1; row >= 0; row--) {
|
|
if (this.board[row].every(cell => cell !== 0)) {
|
|
// Remove the row
|
|
this.board.splice(row, 1);
|
|
// Add empty row at top
|
|
this.board.unshift(Array(BOARD_WIDTH).fill(0));
|
|
rowsCleared++;
|
|
row++; // Check same row again
|
|
}
|
|
}
|
|
|
|
if (rowsCleared > 0) {
|
|
this.lines += rowsCleared;
|
|
this.updateScore(rowsCleared);
|
|
this.updateLevel();
|
|
}
|
|
|
|
return rowsCleared;
|
|
}
|
|
|
|
updateScore(rowsCleared) {
|
|
const points = [0, 100, 300, 500, 800];
|
|
this.score += points[rowsCleared] * this.level;
|
|
}
|
|
|
|
updateLevel() {
|
|
this.level = Math.floor(this.lines / 10) + 1;
|
|
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
|
|
}
|
|
|
|
getGhostY() {
|
|
if (!this.currentPiece) return null;
|
|
|
|
let ghostY = this.currentPiece.y;
|
|
while (this.isValidPosition(this.currentPiece.x, ghostY + 1, this.currentPiece.shape)) {
|
|
ghostY++;
|
|
}
|
|
return ghostY;
|
|
}
|
|
|
|
receiveGarbage(rows) {
|
|
if (this.gameOver) return;
|
|
|
|
// Add garbage rows at bottom
|
|
for (let i = 0; i < rows; i++) {
|
|
// Remove top row (simulating overflow)
|
|
this.board.pop();
|
|
|
|
// Add garbage row with random gap
|
|
const garbageRow = Array(BOARD_WIDTH).fill(GARbage_COLOR);
|
|
const gap = Math.floor(Math.random() * BOARD_WIDTH);
|
|
garbageRow[gap] = 0;
|
|
this.board.unshift(garbageRow);
|
|
}
|
|
|
|
// Check if garbage caused game over
|
|
for (let row = 0; row < 2; row++) {
|
|
for (let col = 0; col < BOARD_WIDTH; col++) {
|
|
if (this.board[row][col] !== 0) {
|
|
this.gameOver = true;
|
|
this.eliminated = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
applyDelayHit(duration) {
|
|
this.inputLocked = true;
|
|
this.lockUntil = Date.now() + duration;
|
|
}
|
|
|
|
update(deltaTime) {
|
|
if (this.gameOver) return;
|
|
|
|
// Check if input lock has expired
|
|
if (this.inputLocked && Date.now() >= this.lockUntil) {
|
|
this.inputLocked = false;
|
|
}
|
|
|
|
// Auto drop
|
|
if (!this.inputLocked && Date.now() - this.lastDrop >= this.dropInterval) {
|
|
this.drop();
|
|
this.lastDrop = Date.now();
|
|
}
|
|
}
|
|
|
|
getState() {
|
|
const ghostY = this.currentPiece ? this.getGhostY() : null;
|
|
return {
|
|
playerId: this.playerId,
|
|
board: JSON.parse(JSON.stringify(this.board)),
|
|
currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null,
|
|
ghostY: ghostY,
|
|
nextPiece: this.nextPiece ? JSON.parse(JSON.stringify(this.nextPiece)) : null,
|
|
holdPiece: this.holdPiece ? JSON.parse(JSON.stringify(this.holdPiece)) : null,
|
|
canHold: this.canHold,
|
|
score: this.score,
|
|
lines: this.lines,
|
|
level: this.level,
|
|
gameOver: this.gameOver,
|
|
eliminated: this.eliminated
|
|
};
|
|
}
|
|
|
|
loadState(state) {
|
|
this.board = JSON.parse(JSON.stringify(state.board));
|
|
this.currentPiece = state.currentPiece ? JSON.parse(JSON.stringify(state.currentPiece)) : null;
|
|
this.nextPiece = state.nextPiece ? JSON.parse(JSON.stringify(state.nextPiece)) : null;
|
|
this.holdPiece = state.holdPiece ? JSON.parse(JSON.stringify(state.holdPiece)) : null;
|
|
this.canHold = state.canHold !== undefined ? state.canHold : true;
|
|
this.score = state.score;
|
|
this.lines = state.lines;
|
|
this.level = state.level;
|
|
this.gameOver = state.gameOver;
|
|
this.eliminated = state.eliminated;
|
|
this.sequenceIndex = state.sequenceIndex || 0;
|
|
this.dropInterval = Math.max(100, 1000 - (this.level - 1) * 100);
|
|
}
|
|
}
|
|
|
|
// Export for use in other modules
|
|
window.TetrisGame = TetrisGame;
|
|
window.TETROMINOS = TETROMINOS;
|
|
window.BOARD_WIDTH = BOARD_WIDTH;
|
|
window.BOARD_HEIGHT = BOARD_HEIGHT;
|
|
window.CELL_SIZE = CELL_SIZE;
|
|
window.GARBAGE_COLOR = GARbage_COLOR;
|