Add hold piece feature
- Added holdPiece and canHold state to TetrisGame class - Implemented hold() method to swap current piece with held piece - Added player-hold socket event on server - Added HOLD preview canvas showing held piece (grayed when unavailable) - Added C key keyboard shortcut and touch button for hold - Fixed canHold reset on piece spawn for proper swap functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,9 +109,46 @@ function setupKeyboardControls() {
|
||||
network.sendHardDrop();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
network.sendHold();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Touch controls for mobile
|
||||
const btnLeft = document.getElementById('btn-left');
|
||||
const btnRight = document.getElementById('btn-right');
|
||||
const btnDown = document.getElementById('btn-down');
|
||||
const btnRotate = document.getElementById('btn-rotate');
|
||||
const btnDrop = document.getElementById('btn-drop');
|
||||
const btnHold = document.getElementById('btn-hold');
|
||||
|
||||
const handleTouch = (e, action) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ui.screens.game.classList.contains('active')) {
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
// Use pointerdown for better touch response
|
||||
btnLeft.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendMove('left')));
|
||||
btnRight.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendMove('right')));
|
||||
btnDown.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendDrop()));
|
||||
btnRotate.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendRotate()));
|
||||
btnDrop.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHardDrop()));
|
||||
btnHold.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHold()));
|
||||
|
||||
// Prevent double-tap zoom
|
||||
btnLeft.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
btnRight.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
btnDown.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
btnRotate.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
btnDrop.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
btnHold.addEventListener('touchstart', (e) => e.preventDefault());
|
||||
}
|
||||
|
||||
function updateBattleGridLayout(playerCount) {
|
||||
|
||||
@@ -24,6 +24,8 @@ class TetrisGame {
|
||||
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;
|
||||
@@ -51,6 +53,8 @@ class TetrisGame {
|
||||
this.gameOver = false;
|
||||
this.eliminated = false;
|
||||
this.dropInterval = 1000;
|
||||
this.holdPiece = null;
|
||||
this.canHold = true;
|
||||
this.currentPiece = this.spawnPiece();
|
||||
this.nextPiece = this.getRandomPiece();
|
||||
|
||||
@@ -172,6 +176,38 @@ class TetrisGame {
|
||||
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;
|
||||
|
||||
@@ -205,6 +241,9 @@ class TetrisGame {
|
||||
this.eliminated = true;
|
||||
}
|
||||
|
||||
// Reset canHold for the new piece
|
||||
this.canHold = true;
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
@@ -294,6 +333,8 @@ class TetrisGame {
|
||||
board: JSON.parse(JSON.stringify(this.board)),
|
||||
currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null,
|
||||
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,
|
||||
@@ -306,6 +347,8 @@ class TetrisGame {
|
||||
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;
|
||||
|
||||
@@ -98,6 +98,11 @@ class NetworkManager {
|
||||
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true });
|
||||
}
|
||||
|
||||
sendHold() {
|
||||
if (!this.socket || !this.currentPlayerId) return;
|
||||
this.socket.emit('player-hold', { playerId: this.currentPlayerId });
|
||||
}
|
||||
|
||||
updatePlayers(players) {
|
||||
this.players = {};
|
||||
players.forEach(p => {
|
||||
|
||||
+69
-2
@@ -43,15 +43,30 @@ class TetrisRenderer {
|
||||
nextDiv.appendChild(nextCanvas);
|
||||
boardDiv.appendChild(nextDiv);
|
||||
|
||||
// Hold piece preview
|
||||
const holdDiv = document.createElement('div');
|
||||
holdDiv.className = 'board-info';
|
||||
const holdCanvas = document.createElement('canvas');
|
||||
holdCanvas.id = `hold-${playerId}`;
|
||||
holdCanvas.width = 80;
|
||||
holdCanvas.height = 80;
|
||||
holdCanvas.style.width = '80px';
|
||||
holdCanvas.style.height = '80px';
|
||||
holdDiv.innerHTML = '<span>HOLD:</span>';
|
||||
holdDiv.appendChild(holdCanvas);
|
||||
boardDiv.appendChild(holdDiv);
|
||||
|
||||
this.container.appendChild(boardDiv);
|
||||
|
||||
this.boards.set(playerId, {
|
||||
element: boardDiv,
|
||||
canvas: canvas,
|
||||
nextCanvas: nextCanvas,
|
||||
holdCanvas: holdCanvas,
|
||||
info: infoDiv,
|
||||
ctx: canvas.getContext('2d'),
|
||||
nextCtx: nextCanvas.getContext('2d')
|
||||
nextCtx: nextCanvas.getContext('2d'),
|
||||
holdCtx: holdCanvas.getContext('2d')
|
||||
});
|
||||
|
||||
return boardDiv;
|
||||
@@ -69,7 +84,7 @@ class TetrisRenderer {
|
||||
const boardData = this.boards.get(playerId);
|
||||
if (!boardData) return;
|
||||
|
||||
const { ctx, nextCtx, element, info } = boardData;
|
||||
const { ctx, nextCtx, holdCtx, element, info } = boardData;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#000';
|
||||
@@ -93,6 +108,15 @@ class TetrisRenderer {
|
||||
this.drawNextPiece(nextCtx, gameState.nextPiece);
|
||||
}
|
||||
|
||||
// Draw hold piece
|
||||
if (gameState && gameState.holdPiece) {
|
||||
this.drawHoldPiece(holdCtx, gameState.holdPiece, gameState.canHold);
|
||||
} else if (holdCtx) {
|
||||
// Clear hold canvas if no hold piece
|
||||
holdCtx.fillStyle = '#000';
|
||||
holdCtx.fillRect(0, 0, holdCtx.canvas.width, holdCtx.canvas.height);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if (gameState) {
|
||||
const linesSpan = info.querySelector('.lines');
|
||||
@@ -222,6 +246,49 @@ class TetrisRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
drawHoldPiece(ctx, piece, canHold) {
|
||||
// Clear
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
if (!piece) return;
|
||||
|
||||
// Gray out if cannot hold again this turn
|
||||
const alpha = canHold ? 1.0 : 0.4;
|
||||
|
||||
// Center the piece
|
||||
const pieceWidth = piece.shape[0].length * 20;
|
||||
const pieceHeight = piece.shape.length * 20;
|
||||
const offsetX = (ctx.canvas.width - pieceWidth) / 2;
|
||||
const offsetY = (ctx.canvas.height - pieceHeight) / 2;
|
||||
|
||||
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 x = offsetX + col * 20;
|
||||
const y = offsetY + row * 20;
|
||||
|
||||
ctx.fillStyle = piece.color + Math.floor(alpha * 255).toString(16).padStart(2, '0');
|
||||
ctx.fillRect(x, y, 20, 20);
|
||||
|
||||
if (canHold) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.fillRect(x, y, 20, 3);
|
||||
ctx.fillRect(x, y, 3, 20);
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.fillRect(x, y + 17, 20, 3);
|
||||
ctx.fillRect(x + 17, y, 3, 20);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, 20, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActivePlayer(playerId) {
|
||||
// Remove main class from all boards
|
||||
this.boards.forEach((boardData, id) => {
|
||||
|
||||
Reference in New Issue
Block a user