a0ab4ff5cd
- Server: Late joiners are added as spectators instead of players - Server: Send forced-spectator event only to joining spectator (not broadcast) - Server: Track spectators separately and move them to players after game ends - Client: Handle forced-spectator event to show all player boards - Client: Spectators see all boards equally without main/spectator highlighting - Client: Mobile view shows scrollable vertical list of all boards for spectators - Fix: All cleared lines are sent as garbage to each opponent (not randomized) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
395 lines
12 KiB
JavaScript
395 lines
12 KiB
JavaScript
// Canvas Renderer for Tetris
|
|
|
|
class TetrisRenderer {
|
|
constructor(containerId) {
|
|
this.container = document.getElementById(containerId);
|
|
this.boards = new Map();
|
|
}
|
|
|
|
createPlayerBoard(playerId, playerName) {
|
|
const boardDiv = document.createElement('div');
|
|
boardDiv.className = 'player-board';
|
|
boardDiv.id = `board-${playerId}`;
|
|
boardDiv.dataset.playerId = playerId;
|
|
|
|
// Main game canvas
|
|
const canvas = document.createElement('canvas');
|
|
canvas.id = `canvas-${playerId}`;
|
|
canvas.width = BOARD_WIDTH * CELL_SIZE;
|
|
canvas.height = BOARD_HEIGHT * CELL_SIZE;
|
|
canvas.style.width = `${BOARD_WIDTH * CELL_SIZE}px`;
|
|
canvas.style.height = `${BOARD_HEIGHT * CELL_SIZE}px`;
|
|
boardDiv.appendChild(canvas);
|
|
|
|
// Player info
|
|
const infoDiv = document.createElement('div');
|
|
infoDiv.className = 'board-info';
|
|
infoDiv.innerHTML = `
|
|
<span class="name">${playerName}</span>
|
|
<span class="stats">Lines: <span class="lines">0</span></span>
|
|
`;
|
|
boardDiv.appendChild(infoDiv);
|
|
|
|
// Next piece preview
|
|
const nextDiv = document.createElement('div');
|
|
nextDiv.className = 'board-info';
|
|
const nextCanvas = document.createElement('canvas');
|
|
nextCanvas.id = `next-${playerId}`;
|
|
nextCanvas.width = 80;
|
|
nextCanvas.height = 80;
|
|
nextCanvas.style.width = '80px';
|
|
nextCanvas.style.height = '80px';
|
|
nextDiv.innerHTML = '<span>NEXT:</span>';
|
|
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'),
|
|
holdCtx: holdCanvas.getContext('2d')
|
|
});
|
|
|
|
return boardDiv;
|
|
}
|
|
|
|
removePlayerBoard(playerId) {
|
|
const board = this.boards.get(playerId);
|
|
if (board) {
|
|
board.element.remove();
|
|
this.boards.delete(playerId);
|
|
}
|
|
}
|
|
|
|
renderPlayer(playerId, gameState) {
|
|
const boardData = this.boards.get(playerId);
|
|
if (!boardData) return;
|
|
|
|
const { ctx, nextCtx, holdCtx, element, info } = boardData;
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
|
// Draw grid lines
|
|
this.drawGrid(ctx);
|
|
|
|
// Draw locked pieces
|
|
if (gameState && gameState.board) {
|
|
this.drawBoard(ctx, gameState.board);
|
|
}
|
|
|
|
// Draw ghost piece (landing preview)
|
|
if (gameState && gameState.currentPiece && !gameState.gameOver && gameState.ghostY !== null) {
|
|
this.drawGhostPiece(ctx, gameState.currentPiece, gameState.ghostY);
|
|
}
|
|
|
|
// Draw current piece
|
|
if (gameState && gameState.currentPiece && !gameState.gameOver) {
|
|
this.drawPiece(ctx, gameState.currentPiece);
|
|
}
|
|
|
|
// Draw next piece
|
|
if (gameState && 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
|
|
if (gameState) {
|
|
const linesSpan = info.querySelector('.lines');
|
|
if (linesSpan) {
|
|
linesSpan.textContent = gameState.lines;
|
|
}
|
|
}
|
|
|
|
// Update board state classes (preserving main/spectator/position classes)
|
|
const wasMain = element.classList.contains('main');
|
|
const wasSpectator = element.classList.contains('spectator');
|
|
const positions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
const wasPos = positions.some(p => element.classList.contains(p));
|
|
element.classList.remove('active', 'eliminated');
|
|
if (wasMain) element.classList.add('main');
|
|
if (wasSpectator) element.classList.add('spectator');
|
|
if (wasPos) {
|
|
positions.forEach(p => {
|
|
if (element.classList.contains(p)) element.classList.add(p);
|
|
});
|
|
}
|
|
if (gameState && gameState.eliminated) {
|
|
element.classList.add('eliminated');
|
|
}
|
|
}
|
|
|
|
drawGrid(ctx) {
|
|
ctx.strokeStyle = '#222';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= BOARD_WIDTH; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * CELL_SIZE, 0);
|
|
ctx.lineTo(x * CELL_SIZE, BOARD_HEIGHT * CELL_SIZE);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = 0; y <= BOARD_HEIGHT; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * CELL_SIZE);
|
|
ctx.lineTo(BOARD_WIDTH * CELL_SIZE, y * CELL_SIZE);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
drawBoard(ctx, board) {
|
|
for (let row = 0; row < BOARD_HEIGHT; row++) {
|
|
for (let col = 0; col < BOARD_WIDTH; col++) {
|
|
const color = board[row][col];
|
|
if (color) {
|
|
this.drawCell(ctx, col, row, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
drawPiece(ctx, piece) {
|
|
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 = piece.x + col;
|
|
const y = piece.y + row;
|
|
if (y >= 0) {
|
|
this.drawCell(ctx, x, y, piece.color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
drawCell(ctx, x, y, color) {
|
|
const px = x * CELL_SIZE;
|
|
const py = y * CELL_SIZE;
|
|
|
|
// Main fill
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(px, py, CELL_SIZE, CELL_SIZE);
|
|
|
|
// Bevel effect
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.fillRect(px, py, CELL_SIZE, 3);
|
|
ctx.fillRect(px, py, 3, CELL_SIZE);
|
|
|
|
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
|
ctx.fillRect(px, py + CELL_SIZE - 3, CELL_SIZE, 3);
|
|
ctx.fillRect(px + CELL_SIZE - 3, py, 3, CELL_SIZE);
|
|
|
|
// Border
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
|
|
}
|
|
|
|
drawGhostPiece(ctx, piece, ghostY) {
|
|
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 = piece.x + col;
|
|
const y = ghostY + row;
|
|
if (y >= 0) {
|
|
this.drawGhostCell(ctx, x, y, piece.color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
drawGhostCell(ctx, x, y, color) {
|
|
const px = x * CELL_SIZE;
|
|
const py = y * CELL_SIZE;
|
|
|
|
// Vibrant outlined border effect
|
|
ctx.strokeStyle = color + 'cc'; // 80% opacity border
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeRect(px + 2, py + 2, CELL_SIZE - 4, CELL_SIZE - 4);
|
|
|
|
// More visible fill inside
|
|
ctx.fillStyle = color + '40'; // 25% opacity
|
|
ctx.fillRect(px + 3, py + 3, CELL_SIZE - 6, CELL_SIZE - 6);
|
|
}
|
|
|
|
drawNextPiece(ctx, piece) {
|
|
// Clear
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
|
// 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;
|
|
ctx.fillRect(x, y, 20, 20);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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/spectator classes from all boards
|
|
this.boards.forEach((boardData, id) => {
|
|
boardData.element.classList.remove('active', 'main');
|
|
boardData.element.classList.remove('spectator', 'top-left', 'top-right', 'bottom-left', 'bottom-right');
|
|
});
|
|
|
|
// If playerId is null (spectator mode), show all boards equally without highlighting
|
|
if (playerId === null) {
|
|
return;
|
|
}
|
|
|
|
// Add main class to current player
|
|
const mainBoard = this.boards.get(playerId);
|
|
if (mainBoard) {
|
|
mainBoard.element.classList.add('main');
|
|
}
|
|
|
|
// Add spectator class to other boards with positions
|
|
const positions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
let posIndex = 0;
|
|
this.boards.forEach((boardData, id) => {
|
|
if (id !== playerId) {
|
|
boardData.element.classList.add('spectator');
|
|
if (posIndex < positions.length) {
|
|
boardData.element.classList.add(positions[posIndex]);
|
|
posIndex++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
triggerShake(playerId) {
|
|
const boardData = this.boards.get(playerId);
|
|
if (boardData) {
|
|
boardData.element.classList.remove('shake');
|
|
void boardData.element.offsetWidth; // Trigger reflow
|
|
boardData.element.classList.add('shake');
|
|
}
|
|
}
|
|
|
|
triggerFlash(playerId) {
|
|
const boardData = this.boards.get(playerId);
|
|
if (boardData) {
|
|
boardData.element.classList.remove('flash');
|
|
void boardData.element.offsetWidth; // Trigger reflow
|
|
boardData.element.classList.add('flash');
|
|
}
|
|
}
|
|
|
|
updatePlayerInfo(playerId, name, score, lines) {
|
|
const boardData = this.boards.get(playerId);
|
|
if (boardData) {
|
|
const nameSpan = boardData.info.querySelector('.name');
|
|
if (nameSpan) nameSpan.textContent = name;
|
|
|
|
const linesSpan = boardData.info.querySelector('.lines');
|
|
if (linesSpan) linesSpan.textContent = lines;
|
|
}
|
|
}
|
|
|
|
clearAll() {
|
|
this.boards.forEach((boardData) => {
|
|
boardData.element.remove();
|
|
});
|
|
this.boards.clear();
|
|
}
|
|
}
|
|
|
|
window.TetrisRenderer = TetrisRenderer;
|