Initial commit: Tetris Battle Royale multiplayer game

Features:
- 2-8 player multiplayer via Socket.io WebSocket
- Real-time board synchronization - all players see all boards
- Battle royale mechanic: clearing rows sends garbage to opponents
- Classic Tetris gameplay with all 7 tetrominoes
- Retro visual styling with CRT scanlines and pixel font
- Automatic level progression and speed increase
- Player elimination and winner announcement

Files:
- server/index.js: Node.js + Socket.io game server
- public/js/: Frontend game logic, rendering, network, and UI
- public/css/style.css: Retro Tetris styling
- README.md: Setup and usage instructions
- PLAN.md: Implementation plan with all phases completed
This commit is contained in:
2026-03-20 00:34:06 -07:00
commit 5da6033704
13 changed files with 3203 additions and 0 deletions
+303
View File
@@ -0,0 +1,303 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #1a1a2e;
color: #fff;
font-family: 'Press Start 2P', cursive;
min-height: 100vh;
overflow-x: hidden;
}
/* CRT Scanline Effect */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1000;
}
#app {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.screen {
display: none;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
max-width: 1200px;
}
.screen.active {
display: flex;
}
h1 {
font-size: 2rem;
text-align: center;
color: #0ff;
text-shadow: 4px 4px 0 #ff00ff;
margin-bottom: 30px;
}
h2 {
font-size: 1.2rem;
color: #0f0;
text-shadow: 2px 2px 0 #004400;
}
h3 {
font-size: 1rem;
color: #ff0;
text-shadow: 2px 2px 0 #444400;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
label {
font-size: 0.7rem;
color: #888;
}
input {
background: #0a0a1a;
border: 2px solid #333;
color: #fff;
font-family: 'Press Start 2P', cursive;
font-size: 0.8rem;
padding: 15px;
text-align: center;
outline: none;
}
input:focus {
border-color: #0ff;
}
button {
background: #ff00ff;
border: 4px solid #fff;
color: #fff;
font-family: 'Press Start 2P', cursive;
font-size: 1rem;
padding: 15px 30px;
cursor: pointer;
text-transform: uppercase;
transition: transform 0.1s;
}
button:hover {
transform: scale(1.05);
background: #0ff;
color: #000;
}
button:active {
transform: scale(0.95);
}
#player-list {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
min-height: 150px;
}
.player-item {
background: #0a0a1a;
border: 2px solid #333;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
}
.player-item .name {
color: #0ff;
}
.player-item .status {
color: #888;
}
.player-item .status.ready {
color: #0f0;
}
/* Battle Grid - Responsive Layout */
#battle-grid {
display: grid;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
#battle-grid.grid-2x2 {
grid-template-columns: repeat(2, 1fr);
}
#battle-grid.grid-2x4 {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
}
.player-board {
background: #000;
border: 4px solid #333;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.player-board.active {
border-color: #0ff;
}
.player-board.eliminated {
border-color: #f00;
opacity: 0.5;
}
.player-board canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.board-info {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 5px;
font-size: 0.6rem;
}
.board-info .name {
color: #0ff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-info .stats {
color: #888;
}
.next-piece-container {
display: flex;
align-items: center;
gap: 5px;
}
.next-piece-container canvas {
border: 1px solid #333;
}
#game-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px 20px;
background: #0a0a1a;
border: 2px solid #333;
margin-bottom: 20px;
}
#game-header span {
font-size: 0.8rem;
color: #0f0;
}
#game-header button {
font-size: 0.7rem;
padding: 10px 15px;
}
#game-status {
margin-top: 20px;
padding: 15px;
background: #0a0a1a;
border: 2px solid #333;
text-align: center;
font-size: 0.8rem;
color: #ff0;
}
#winner-display {
margin: 20px 0;
font-size: 1.2rem;
}
#final-scores {
display: flex;
flex-direction: column;
gap: 10px;
margin: 20px 0;
width: 100%;
max-width: 400px;
}
#final-scores .score-item {
background: #0a0a1a;
border: 2px solid #333;
padding: 10px 15px;
display: flex;
justify-content: space-between;
}
#final-scores .score-item.winner {
border-color: #0f0;
color: #0f0;
}
/* Garbage row animation */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.player-board.shake {
animation: shake 0.2s ease-in-out;
}
/* Flash effect for row clear */
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.player-board.flash {
animation: flash 0.3s ease-in-out;
}
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tetris Battle Royale</title>
<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">
</head>
<body>
<div id="app">
<!-- Room Selection Screen -->
<div id="room-screen" class="screen active">
<h1>TETRIS<br>BATTLE ROYALE</h1>
<div class="form-group">
<label for="room-name">Room Name</label>
<input type="text" id="room-name" placeholder="Enter room name">
</div>
<div class="form-group">
<label for="player-name">Your Name</label>
<input type="text" id="player-name" placeholder="Enter your name">
</div>
<button id="join-btn">JOIN ROOM</button>
</div>
<!-- Lobby Screen -->
<div id="lobby-screen" class="screen">
<h2>Room: <span id="lobby-room-name"></span></h2>
<div id="player-list"></div>
<button id="ready-btn">READY</button>
</div>
<!-- Game Screen -->
<div id="game-screen" class="screen">
<div id="game-header">
<span id="game-room-name"></span>
<button id="leave-btn">LEAVE</button>
</div>
<div id="battle-grid"></div>
<div id="game-status"></div>
</div>
<!-- Game Over Screen -->
<div id="gameover-screen" class="screen">
<h2>GAME OVER</h2>
<h3 id="winner-display"></h3>
<div id="final-scores"></div>
<button id="back-to-lobby">BACK TO LOBBY</button>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/network.js"></script>
<script src="js/game.js"></script>
<script src="js/renderer.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
// Main Application - Ties everything together
let localGame = null;
let renderer = null;
let lastTime = 0;
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Connect to server
network.connect();
// Initialize renderer
renderer = new TetrisRenderer('battle-grid');
// Setup network listeners
setupNetworkListeners();
// Setup keyboard controls
setupKeyboardControls();
});
function setupNetworkListeners() {
// Player joined lobby
network.setListener('player-joined', (player) => {
ui.updatePlayerList(network.getAllPlayers());
});
// Player left lobby
network.setListener('player-left', (playerId) => {
ui.updatePlayerList(network.getAllPlayers());
});
// Game started
network.setListener('game-started', (players, states) => {
ui.showScreen('game');
ui.displays.gameRoomName.textContent = network.currentRoom;
// Clear old boards
renderer.clearAll();
// Create boards for all players
states.forEach((state) => {
const player = network.getPlayer(state.playerId);
renderer.createPlayerBoard(state.playerId, player.name);
// Initialize local game for current player
if (state.playerId === network.currentPlayerId) {
localGame = new TetrisGame(state.playerId);
localGame.loadState(state);
}
});
// Set up battle grid layout
updateBattleGridLayout(players.length);
// Start game loop
lastTime = performance.now();
requestAnimationFrame(gameLoop);
});
// State update during game
network.setListener('state-update', (states) => {
// Update local game if it's our state
const localState = states.find(s => s.playerId === network.currentPlayerId);
if (localState) {
localGame.loadState(localState);
}
// Check for game over
const allStates = network.getAllGameStates();
const activePlayers = Object.values(allStates).filter(s => !s.eliminated);
if (activePlayers.length <= 1) {
endGame(allStates);
}
});
// Game over
network.setListener('game-over', (data) => {
endGame(data.states);
});
}
function setupKeyboardControls() {
document.addEventListener('keydown', (e) => {
if (ui.screens.game.classList.contains('active') && localGame) {
switch (e.key) {
case 'ArrowLeft':
network.sendMove('left');
e.preventDefault();
break;
case 'ArrowRight':
network.sendMove('right');
e.preventDefault();
break;
case 'ArrowDown':
network.sendDrop();
e.preventDefault();
break;
case 'ArrowUp':
network.sendRotate();
e.preventDefault();
break;
case ' ':
network.sendHardDrop();
e.preventDefault();
break;
}
}
});
}
function updateBattleGridLayout(playerCount) {
ui.displays.battleGrid.classList.remove('grid-2x2', 'grid-2x4');
if (playerCount <= 4) {
ui.displays.battleGrid.classList.add('grid-2x2');
} else {
ui.displays.battleGrid.classList.add('grid-2x4');
}
}
function endGame(states) {
// Find winner
const activePlayers = Object.values(states).filter(s => !s.eliminated);
const eliminatedPlayers = Object.values(states).filter(s => s.eliminated);
let winner = null;
let scores = {};
if (activePlayers.length === 1) {
const winnerState = activePlayers[0];
const winnerPlayer = network.getPlayer(winnerState.playerId);
winner = winnerPlayer.name;
}
// Build scores list
Object.values(states).forEach(state => {
const player = network.getPlayer(state.playerId);
if (player) {
scores[player.name] = state.score;
}
});
ui.showGameOver(winner, scores);
}
function gameLoop(currentTime) {
if (!ui.screens.game.classList.contains('active')) {
return;
}
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
// Update local game
if (localGame) {
localGame.update(deltaTime);
}
// Render all players
const allStates = network.getAllGameStates();
Object.values(allStates).forEach(state => {
renderer.renderPlayer(state.playerId, state);
});
// Set active player highlight
renderer.setActivePlayer(network.currentPlayerId);
requestAnimationFrame(gameLoop);
}
+324
View File
@@ -0,0 +1,324 @@
// 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.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.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;
}
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)
if (this.currentPiece.y <= 0) {
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;
}
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);
}
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() {
return {
playerId: this.playerId,
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,
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.score = state.score;
this.lines = state.lines;
this.level = state.level;
this.gameOver = state.gameOver;
this.eliminated = state.eliminated;
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;
+153
View File
@@ -0,0 +1,153 @@
// Network module - Socket.io client handling
class NetworkManager {
constructor() {
this.socket = null;
this.currentRoom = null;
this.currentPlayerId = null;
this.players = {};
this.gameState = {};
this.listeners = {
onPlayerJoined: null,
onPlayerLeft: null,
onGameStarted: null,
onStateUpdate: null,
onGameOver: null
};
}
connect() {
this.socket = io();
this.socket.on('connect', () => {
console.log('Connected to server');
this.currentPlayerId = this.socket.id;
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.currentRoom = null;
});
this.socket.on('player-joined', ({ player, players }) => {
this.updatePlayers(players);
if (this.listeners.onPlayerJoined) {
this.listeners.onPlayerJoined(player);
}
});
this.socket.on('player-left', ({ playerId, players }) => {
delete this.players[playerId];
this.updatePlayers(players);
if (this.listeners.onPlayerLeft) {
this.listeners.onPlayerLeft(playerId);
}
});
this.socket.on('game-started', ({ players, states }) => {
this.updatePlayers(players);
this.updateStates(states);
if (this.listeners.onGameStarted) {
this.listeners.onGameStarted(players, states);
}
});
this.socket.on('state-update', (states) => {
this.updateStates(states);
if (this.listeners.onStateUpdate) {
this.listeners.onStateUpdate(states);
}
});
this.socket.on('game-over', (data) => {
if (this.listeners.onGameOver) {
this.listeners.onGameOver(data);
}
});
}
joinRoom(roomName, playerName) {
if (!this.socket) return;
this.currentRoom = roomName;
this.socket.emit('join-room', { roomName, playerName });
}
leaveRoom() {
if (!this.socket) return;
if (this.currentRoom) {
this.socket.leave(this.currentRoom);
}
this.currentRoom = null;
}
ready() {
if (!this.socket) return;
this.socket.emit('ready');
}
sendMove(direction) {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-move', { playerId: this.currentPlayerId, direction });
}
sendRotate() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-rotate', { playerId: this.currentPlayerId });
}
sendDrop() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: false });
}
sendHardDrop() {
if (!this.socket || !this.currentPlayerId) return;
this.socket.emit('player-drop', { playerId: this.currentPlayerId, hard: true });
}
updatePlayers(players) {
this.players = {};
players.forEach(p => {
this.players[p.id] = p;
});
}
updateStates(states) {
states.forEach(s => {
this.gameState[s.playerId] = s;
});
}
getPlayer(playerId) {
return this.players[playerId];
}
getGameState(playerId) {
return this.gameState[playerId];
}
getAllPlayers() {
return this.players;
}
getAllGameStates() {
return this.gameState;
}
setListener(event, callback) {
const listenerMap = {
'player-joined': 'onPlayerJoined',
'player-left': 'onPlayerLeft',
'game-started': 'onGameStarted',
'state-update': 'onStateUpdate',
'game-over': 'onGameOver'
};
if (callback) {
this.listeners[listenerMap[event]] = callback;
}
}
}
const network = new NetworkManager();
+264
View File
@@ -0,0 +1,264 @@
// 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);
this.container.appendChild(boardDiv);
this.boards.set(playerId, {
element: boardDiv,
canvas: canvas,
nextCanvas: nextCanvas,
info: infoDiv,
ctx: canvas.getContext('2d'),
nextCtx: nextCanvas.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, 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 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);
}
// Update stats
if (gameState) {
const linesSpan = info.querySelector('.lines');
if (linesSpan) {
linesSpan.textContent = gameState.lines;
}
}
// Update board state classes
element.classList.remove('active', 'eliminated');
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);
}
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);
}
}
}
}
setActivePlayer(playerId) {
// Remove active class from all boards
this.boards.forEach((boardData) => {
boardData.element.classList.remove('active');
});
// Add to current player
const boardData = this.boards.get(playerId);
if (boardData) {
boardData.element.classList.add('active');
}
}
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;
+138
View File
@@ -0,0 +1,138 @@
// UI Module - Handle screens and user interactions
class UIManager {
constructor() {
this.screens = {
room: document.getElementById('room-screen'),
lobby: document.getElementById('lobby-screen'),
game: document.getElementById('game-screen'),
gameover: document.getElementById('gameover-screen')
};
this.inputs = {
roomName: document.getElementById('room-name'),
playerName: document.getElementById('player-name')
};
this.buttons = {
join: document.getElementById('join-btn'),
ready: document.getElementById('ready-btn'),
leave: document.getElementById('leave-btn'),
backToLobby: document.getElementById('back-to-lobby')
};
this.displays = {
lobbyRoomName: document.getElementById('lobby-room-name'),
gameRoomName: document.getElementById('game-room-name'),
playerList: document.getElementById('player-list'),
battleGrid: document.getElementById('battle-grid'),
gameStatus: document.getElementById('game-status'),
winnerDisplay: document.getElementById('winner-display'),
finalScores: document.getElementById('final-scores')
};
this.bindEvents();
}
bindEvents() {
this.buttons.join.addEventListener('click', () => this.handleJoin());
this.buttons.ready.addEventListener('click', () => this.handleReady());
this.buttons.leave.addEventListener('click', () => this.handleLeave());
this.buttons.backToLobby.addEventListener('click', () => this.handleBackToLobby());
// Allow Enter key to submit forms
this.inputs.roomName.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleJoin();
});
this.inputs.playerName.addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.handleJoin();
});
}
showScreen(screenName) {
Object.values(this.screens).forEach(screen => screen.classList.remove('active'));
this.screens[screenName].classList.add('active');
}
handleJoin() {
const roomName = this.inputs.roomName.value.trim();
const playerName = this.inputs.playerName.value.trim();
if (!roomName || !playerName) {
this.showMessage('Please enter room name and your name');
return;
}
// Emit join event
network.setListener('player-joined', () => {
this.showScreen('lobby');
this.displays.lobbyRoomName.textContent = roomName;
});
network.joinRoom(roomName, playerName);
}
handleReady() {
network.ready();
this.buttons.ready.textContent = 'READY!';
this.buttons.ready.disabled = true;
}
handleLeave() {
network.leaveRoom();
this.showScreen('room');
}
handleBackToLobby() {
this.showScreen('room');
}
updatePlayerList(players) {
this.displays.playerList.innerHTML = '';
Object.values(players).forEach(player => {
const item = document.createElement('div');
item.className = 'player-item';
const statusClass = player.ready ? 'ready' : '';
item.innerHTML = `
<span class="name">${this.escapeHtml(player.name)}</span>
<span class="status ${statusClass}">${player.ready ? 'READY' : 'WAITING'}</span>
`;
this.displays.playerList.appendChild(item);
});
}
showMessage(message) {
this.displays.gameStatus.textContent = message;
setTimeout(() => {
this.displays.gameStatus.textContent = '';
}, 3000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showGameOver(winner, scores) {
this.displays.winnerDisplay.textContent = winner
? `Winner: ${winner}!`
: 'Game Over!';
this.displays.winnerDisplay.style.color = winner ? '#0f0' : '#fff';
this.displays.finalScores.innerHTML = '';
Object.entries(scores).forEach(([name, score], index) => {
const item = document.createElement('div');
item.className = 'score-item' + (index === 0 ? ' winner' : '');
item.innerHTML = `
<span>${this.escapeHtml(name)}</span>
<span>${score}</span>
`;
this.displays.finalScores.appendChild(item);
});
this.showScreen('gameover');
}
}
window.ui = new UIManager();