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:
2026-03-20 08:50:52 -07:00
parent cde1643606
commit 4a49c76cdc
8 changed files with 410 additions and 8 deletions
+121
View File
@@ -289,6 +289,19 @@ button:active {
border: 1px solid #333;
}
/* Hold and Next piece previews side by side */
.board-info:has(canvas) {
justify-content: center;
gap: 20px;
padding: 8px 5px;
}
.board-info canvas {
border: 1px solid #333;
background: #000;
}
}
#game-header {
display: flex;
justify-content: space-between;
@@ -399,3 +412,111 @@ button:active {
.player-board.flash {
animation: flash 0.3s ease-in-out;
}
/* Touch Controls */
#touch-controls {
display: none;
width: 100%;
max-width: 400px;
margin-top: 20px;
gap: 20px;
justify-content: center;
align-items: flex-end;
padding-bottom: 20px;
}
#touch-dpad {
display: grid;
grid-template-columns: repeat(3, 70px);
grid-template-rows: repeat(2, 70px);
gap: 8px;
}
#btn-left {
grid-column: 1;
grid-row: 2;
}
#btn-down {
grid-column: 2;
grid-row: 2;
}
#btn-right {
grid-column: 3;
grid-row: 2;
}
#touch-actions {
display: grid;
grid-template-columns: repeat(2, 70px);
grid-template-rows: repeat(2, 70px);
gap: 8px;
}
#btn-rotate {
grid-column: 1;
grid-row: 1;
}
#btn-drop {
grid-column: 2;
grid-row: 1;
}
#btn-hold {
grid-column: 1;
grid-row: 2;
}
.touch-btn {
width: 70px;
height: 70px;
font-size: 1.5rem;
background: #333;
border: 3px solid #555;
border-radius: 10px;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
.touch-btn:active {
background: #0ff;
color: #000;
transform: scale(0.95);
}
.touch-btn.action-btn {
background: #444;
}
.touch-btn.drop-btn {
background: #ff00ff;
border-color: #ff00ff;
}
.touch-btn.hold-btn {
background: #ff8800;
border-color: #ff8800;
font-size: 0.9rem;
}
/* Show touch controls on mobile only */
@media (max-width: 768px) and (hover: none) {
#touch-controls {
display: flex;
}
#battle-grid {
height: 60vh;
}
.player-board.spectator {
display: none;
}
}
+19 -6
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=2">
<link rel="stylesheet" href="css/style.css?v=6">
</head>
<body>
<div id="app">
@@ -36,6 +36,19 @@
</div>
<div id="battle-grid"></div>
<div id="game-status"></div>
<!-- Touch Controls -->
<div id="touch-controls">
<div id="touch-dpad">
<button id="btn-left" class="touch-btn"></button>
<button id="btn-down" class="touch-btn"></button>
<button id="btn-right" class="touch-btn"></button>
</div>
<div id="touch-actions">
<button id="btn-rotate" class="touch-btn action-btn"></button>
<button id="btn-drop" class="touch-btn drop-btn"></button>
<button id="btn-hold" class="touch-btn hold-btn">HOLD</button>
</div>
</div>
</div>
<!-- Game Over Screen (Overlay) -->
@@ -48,10 +61,10 @@
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/network.js?v=2"></script>
<script src="js/game.js?v=2"></script>
<script src="js/renderer.js?v=2"></script>
<script src="js/ui.js?v=2"></script>
<script src="js/app.js?v=2"></script>
<script src="js/network.js?v=3"></script>
<script src="js/game.js?v=3"></script>
<script src="js/renderer.js?v=3"></script>
<script src="js/ui.js?v=3"></script>
<script src="js/app.js?v=3"></script>
</body>
</html>
+37
View File
@@ -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) {
+43
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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) => {