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:
@@ -0,0 +1,73 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Multiplayer Tetris Battle Royale game with 2-8 player real-time battles via WebSocket. Players clear rows to send garbage to opponents; last player standing wins.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
cd server && npm install
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
cd server && npm start
|
||||||
|
# or
|
||||||
|
cd server && node index.js
|
||||||
|
|
||||||
|
# Server runs on http://localhost:3000 (or PORT env variable)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server-Client Model
|
||||||
|
|
||||||
|
- **Server** (`server/index.js`): Express + Socket.io handles all game logic authoritatively
|
||||||
|
- Manages single global lobby with `lobby.players` Map
|
||||||
|
- Game tick runs at 50ms intervals via `gameTick()`
|
||||||
|
- Broadcasts state updates to all connected clients via `broadcastState()`
|
||||||
|
- Tetromino definitions and board constants are duplicated on server
|
||||||
|
|
||||||
|
- **Client** (`public/js/`): Vanilla JavaScript with module pattern
|
||||||
|
- `network.js`: Socket.io client wrapper, manages player/game state caching
|
||||||
|
- `game.js`: `TetrisGame` class for local game state (mirrors server)
|
||||||
|
- `renderer.js`: `TetrisRenderer` class - Canvas rendering for all player boards
|
||||||
|
- `ui.js`: `UIManager` class - screen transitions, DOM manipulation
|
||||||
|
- `app.js`: Entry point, ties modules together, game loop via `requestAnimationFrame`
|
||||||
|
|
||||||
|
### Game Flow
|
||||||
|
|
||||||
|
1. **Lobby**: Players join global lobby via `join-lobby` socket event
|
||||||
|
2. **Ready**: All players click READY; game starts when all ready (2-8 players)
|
||||||
|
3. **Game**: Server authoritative - client inputs sent via socket, server broadcasts state
|
||||||
|
4. **Elimination**: Player eliminated when piece locks at top or garbage fills board
|
||||||
|
5. **Victory**: Game ends when 1 active player remains
|
||||||
|
|
||||||
|
### Key Constants
|
||||||
|
|
||||||
|
- `BOARD_WIDTH = 10`, `BOARD_HEIGHT = 20`, `CELL_SIZE = 24`
|
||||||
|
- `LOBBY_ROOM = 'global-lobby'` - Socket.io room for all players
|
||||||
|
- Garbage rules: 2 lines cleared -> 1 garbage row, 3 -> 2, 4 (Tetris) -> 4 rows
|
||||||
|
|
||||||
|
### Socket Events
|
||||||
|
|
||||||
|
| Event | Direction | Payload |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `join-lobby` | Client->Server | `{ playerName }` |
|
||||||
|
| `ready` | Client->Server | - |
|
||||||
|
| `player-move` | Client->Server | `{ playerId, direction }` |
|
||||||
|
| `player-rotate` | Client->Server | `{ playerId }` |
|
||||||
|
| `player-drop` | Client->Server | `{ playerId, hard }` |
|
||||||
|
| `player-joined` | Server->Client | `{ player, players }` |
|
||||||
|
| `player-left` | Server->Client | `{ playerId, players }` |
|
||||||
|
| `game-started` | Server->Client | `{ players, states }` |
|
||||||
|
| `state-update` | Server->Client | `states[]` |
|
||||||
|
| `game-over` | Server->Client | `{ states }` |
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
- Each player gets a dynamically created board with canvas + info divs
|
||||||
|
- `renderer.setActivePlayer()` marks current player's board as `.main`, others as `.spectator`
|
||||||
|
- Battle grid layout classes: `.single-player`, `.two-players`, `.multi-player`
|
||||||
@@ -289,6 +289,19 @@ button:active {
|
|||||||
border: 1px solid #333;
|
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 {
|
#game-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -399,3 +412,111 @@ button:active {
|
|||||||
.player-board.flash {
|
.player-board.flash {
|
||||||
animation: flash 0.3s ease-in-out;
|
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
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -36,6 +36,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="battle-grid"></div>
|
<div id="battle-grid"></div>
|
||||||
<div id="game-status"></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>
|
</div>
|
||||||
|
|
||||||
<!-- Game Over Screen (Overlay) -->
|
<!-- Game Over Screen (Overlay) -->
|
||||||
@@ -48,10 +61,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
<script src="js/network.js?v=2"></script>
|
<script src="js/network.js?v=3"></script>
|
||||||
<script src="js/game.js?v=2"></script>
|
<script src="js/game.js?v=3"></script>
|
||||||
<script src="js/renderer.js?v=2"></script>
|
<script src="js/renderer.js?v=3"></script>
|
||||||
<script src="js/ui.js?v=2"></script>
|
<script src="js/ui.js?v=3"></script>
|
||||||
<script src="js/app.js?v=2"></script>
|
<script src="js/app.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -109,9 +109,46 @@ function setupKeyboardControls() {
|
|||||||
network.sendHardDrop();
|
network.sendHardDrop();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
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) {
|
function updateBattleGridLayout(playerCount) {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class TetrisGame {
|
|||||||
this.board = this.createEmptyBoard();
|
this.board = this.createEmptyBoard();
|
||||||
this.currentPiece = null;
|
this.currentPiece = null;
|
||||||
this.nextPiece = null;
|
this.nextPiece = null;
|
||||||
|
this.holdPiece = null;
|
||||||
|
this.canHold = true;
|
||||||
this.score = 0;
|
this.score = 0;
|
||||||
this.lines = 0;
|
this.lines = 0;
|
||||||
this.level = 1;
|
this.level = 1;
|
||||||
@@ -51,6 +53,8 @@ class TetrisGame {
|
|||||||
this.gameOver = false;
|
this.gameOver = false;
|
||||||
this.eliminated = false;
|
this.eliminated = false;
|
||||||
this.dropInterval = 1000;
|
this.dropInterval = 1000;
|
||||||
|
this.holdPiece = null;
|
||||||
|
this.canHold = true;
|
||||||
this.currentPiece = this.spawnPiece();
|
this.currentPiece = this.spawnPiece();
|
||||||
this.nextPiece = this.getRandomPiece();
|
this.nextPiece = this.getRandomPiece();
|
||||||
|
|
||||||
@@ -172,6 +176,38 @@ class TetrisGame {
|
|||||||
return dropped;
|
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() {
|
lockPiece() {
|
||||||
if (!this.currentPiece) return false;
|
if (!this.currentPiece) return false;
|
||||||
|
|
||||||
@@ -205,6 +241,9 @@ class TetrisGame {
|
|||||||
this.eliminated = true;
|
this.eliminated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset canHold for the new piece
|
||||||
|
this.canHold = true;
|
||||||
|
|
||||||
return cleared;
|
return cleared;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +333,8 @@ class TetrisGame {
|
|||||||
board: JSON.parse(JSON.stringify(this.board)),
|
board: JSON.parse(JSON.stringify(this.board)),
|
||||||
currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null,
|
currentPiece: this.currentPiece ? JSON.parse(JSON.stringify(this.currentPiece)) : null,
|
||||||
nextPiece: this.nextPiece ? JSON.parse(JSON.stringify(this.nextPiece)) : 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,
|
score: this.score,
|
||||||
lines: this.lines,
|
lines: this.lines,
|
||||||
level: this.level,
|
level: this.level,
|
||||||
@@ -306,6 +347,8 @@ class TetrisGame {
|
|||||||
this.board = JSON.parse(JSON.stringify(state.board));
|
this.board = JSON.parse(JSON.stringify(state.board));
|
||||||
this.currentPiece = state.currentPiece ? JSON.parse(JSON.stringify(state.currentPiece)) : null;
|
this.currentPiece = state.currentPiece ? JSON.parse(JSON.stringify(state.currentPiece)) : null;
|
||||||
this.nextPiece = state.nextPiece ? JSON.parse(JSON.stringify(state.nextPiece)) : 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.score = state.score;
|
||||||
this.lines = state.lines;
|
this.lines = state.lines;
|
||||||
this.level = state.level;
|
this.level = state.level;
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ class NetworkManager {
|
|||||||
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true });
|
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) {
|
updatePlayers(players) {
|
||||||
this.players = {};
|
this.players = {};
|
||||||
players.forEach(p => {
|
players.forEach(p => {
|
||||||
|
|||||||
+69
-2
@@ -43,15 +43,30 @@ class TetrisRenderer {
|
|||||||
nextDiv.appendChild(nextCanvas);
|
nextDiv.appendChild(nextCanvas);
|
||||||
boardDiv.appendChild(nextDiv);
|
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.container.appendChild(boardDiv);
|
||||||
|
|
||||||
this.boards.set(playerId, {
|
this.boards.set(playerId, {
|
||||||
element: boardDiv,
|
element: boardDiv,
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
nextCanvas: nextCanvas,
|
nextCanvas: nextCanvas,
|
||||||
|
holdCanvas: holdCanvas,
|
||||||
info: infoDiv,
|
info: infoDiv,
|
||||||
ctx: canvas.getContext('2d'),
|
ctx: canvas.getContext('2d'),
|
||||||
nextCtx: nextCanvas.getContext('2d')
|
nextCtx: nextCanvas.getContext('2d'),
|
||||||
|
holdCtx: holdCanvas.getContext('2d')
|
||||||
});
|
});
|
||||||
|
|
||||||
return boardDiv;
|
return boardDiv;
|
||||||
@@ -69,7 +84,7 @@ class TetrisRenderer {
|
|||||||
const boardData = this.boards.get(playerId);
|
const boardData = this.boards.get(playerId);
|
||||||
if (!boardData) return;
|
if (!boardData) return;
|
||||||
|
|
||||||
const { ctx, nextCtx, element, info } = boardData;
|
const { ctx, nextCtx, holdCtx, element, info } = boardData;
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
@@ -93,6 +108,15 @@ class TetrisRenderer {
|
|||||||
this.drawNextPiece(nextCtx, gameState.nextPiece);
|
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
|
// Update stats
|
||||||
if (gameState) {
|
if (gameState) {
|
||||||
const linesSpan = info.querySelector('.lines');
|
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) {
|
setActivePlayer(playerId) {
|
||||||
// Remove main class from all boards
|
// Remove main class from all boards
|
||||||
this.boards.forEach((boardData, id) => {
|
this.boards.forEach((boardData, id) => {
|
||||||
|
|||||||
@@ -159,6 +159,42 @@ io.on('connection', (socket) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('player-hold', ({ playerId }) => {
|
||||||
|
if (!lobby.gameStarted) return;
|
||||||
|
|
||||||
|
const player = lobby.players.get(playerId);
|
||||||
|
if (!player || player.eliminated) return;
|
||||||
|
if (!player.canHold) return;
|
||||||
|
|
||||||
|
if (player.holdPiece === null) {
|
||||||
|
// First hold - store current piece and spawn next
|
||||||
|
player.holdPiece = {
|
||||||
|
type: player.currentPiece.type,
|
||||||
|
shape: JSON.parse(JSON.stringify(player.currentPiece.shape)),
|
||||||
|
color: player.currentPiece.color
|
||||||
|
};
|
||||||
|
player.currentPiece = player.nextPiece;
|
||||||
|
player.nextPiece = getRandomPiece();
|
||||||
|
} else {
|
||||||
|
// Swap with held piece
|
||||||
|
const temp = {
|
||||||
|
type: player.currentPiece.type,
|
||||||
|
shape: JSON.parse(JSON.stringify(player.currentPiece.shape)),
|
||||||
|
color: player.currentPiece.color
|
||||||
|
};
|
||||||
|
player.currentPiece = {
|
||||||
|
...player.holdPiece,
|
||||||
|
shape: JSON.parse(JSON.stringify(player.holdPiece.shape)),
|
||||||
|
x: Math.floor(BOARD_WIDTH / 2) - Math.floor(player.holdPiece.shape[0].length / 2),
|
||||||
|
y: 0
|
||||||
|
};
|
||||||
|
player.holdPiece = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.canHold = false;
|
||||||
|
broadcastState();
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
const player = lobby.players.get(socket.id);
|
const player = lobby.players.get(socket.id);
|
||||||
if (player) {
|
if (player) {
|
||||||
@@ -254,6 +290,9 @@ function lockPiece(player) {
|
|||||||
|
|
||||||
if (!spawnPiece(player)) player.eliminated = true;
|
if (!spawnPiece(player)) player.eliminated = true;
|
||||||
|
|
||||||
|
// Reset canHold for the new piece
|
||||||
|
player.canHold = true;
|
||||||
|
|
||||||
if (rowsCleared > 0) sendGarbage(player, rowsCleared);
|
if (rowsCleared > 0) sendGarbage(player, rowsCleared);
|
||||||
|
|
||||||
broadcastState();
|
broadcastState();
|
||||||
@@ -309,6 +348,8 @@ function startGame() {
|
|||||||
player.level = 1;
|
player.level = 1;
|
||||||
player.eliminated = false;
|
player.eliminated = false;
|
||||||
player.dropInterval = 1000;
|
player.dropInterval = 1000;
|
||||||
|
player.holdPiece = null;
|
||||||
|
player.canHold = true;
|
||||||
player.currentPiece = getRandomPiece();
|
player.currentPiece = getRandomPiece();
|
||||||
player.nextPiece = getRandomPiece();
|
player.nextPiece = getRandomPiece();
|
||||||
}
|
}
|
||||||
@@ -351,6 +392,8 @@ function getStates() {
|
|||||||
board: JSON.parse(JSON.stringify(p.board)),
|
board: JSON.parse(JSON.stringify(p.board)),
|
||||||
currentPiece: p.currentPiece ? JSON.parse(JSON.stringify(p.currentPiece)) : null,
|
currentPiece: p.currentPiece ? JSON.parse(JSON.stringify(p.currentPiece)) : null,
|
||||||
nextPiece: p.nextPiece ? JSON.parse(JSON.stringify(p.nextPiece)) : null,
|
nextPiece: p.nextPiece ? JSON.parse(JSON.stringify(p.nextPiece)) : null,
|
||||||
|
holdPiece: p.holdPiece ? JSON.parse(JSON.stringify(p.holdPiece)) : null,
|
||||||
|
canHold: p.canHold,
|
||||||
score: p.score,
|
score: p.score,
|
||||||
lines: p.lines,
|
lines: p.lines,
|
||||||
level: p.level,
|
level: p.level,
|
||||||
|
|||||||
Reference in New Issue
Block a user