diff --git a/CLAUDE.md b/CLAUDE.md index 259ae89..4461829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,14 +79,39 @@ This design gives players a buffer zone to recover from garbage attacks before e | `player-move` | Client->Server | `{ playerId, direction }` | | `player-rotate` | Client->Server | `{ playerId }` | | `player-drop` | Client->Server | `{ playerId, hard }` | +| `player-hold` | Client->Server | `{ playerId }` | +| `zone-activate` | Client->Server | `{ playerId }` | | `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 }` | +### Zone Mechanic (Tetris Effect) + +Zone is a time-based attack mechanic imported from Tetris Effect. When players clear lines, their **Zone meter** fills: +- **1 line** = +15 Zone meter +- **2 lines** = +25 Zone meter +- **3 lines** = +35 Zone meter +- **4 lines (Tetris)** = +50 Zone meter + +**When Zone meter reaches 100:** +- Press **Z key** or **ZONE button** to activate Zone +- Gravity pauses for **20 seconds** +- Players control pieces manually but gravity is frozen +- Cleared lines **cascade to bottom** instead of disappearing +- After 20 seconds, Zone ends and sends garbage to opponents + +**Zone Attack Calculation:** +- Lines cleared during Zone × 1.5 = garbage rows sent +- Example: 4 Zone lines = 6 garbage rows, 8 Zone lines = 12 garbage rows +- Garbage is distributed randomly among remaining opponents + +Zone provides a high-risk, high-reward tactic for eliminating opponents with massive attacks. + ### Rendering - Each player gets a dynamically created board with canvas + info divs - `renderer.setActivePlayer()` marks current player's board as `.main`, others as `.spectator` +- Zone meter shown below each player board with fill bar and status indicator - Battle grid layout classes: `.single-player`, `.two-players`, `.multi-player` diff --git a/public/audio/tetris_theme_og.mp3 b/public/audio/tetris_theme_og.mp3 new file mode 100644 index 0000000..5c3f4bd Binary files /dev/null and b/public/audio/tetris_theme_og.mp3 differ diff --git a/public/css/style.css b/public/css/style.css index 30029a8..e5d457e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -337,6 +337,38 @@ button:active { border: 1px solid #333; background: #000; } + +/* Zone meter styling */ +.zone-info { + font-size: 0.5rem !important; + gap: 8px; + justify-content: center; + padding: 8px 5px !important; +} + +.zone-meter-bar { + display: inline-block; + width: 100px; + height: 12px; + border: 1px solid #0ff; + background: #000; + position: relative; + overflow: hidden; +} + +.zone-meter-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, #00ff00, #ffff00, #ff6600); + width: 0%; + transition: width 0.1s ease; +} + +.zone-status { + color: #fff; + font-size: 0.45rem; +} + } #game-header { @@ -554,6 +586,19 @@ button:active { font-size: 0.9rem; } +.touch-btn.zone-btn { + background: #8800ff; + border-color: #8800ff; + font-size: 0.9rem; +} + +.touch-btn.zone-btn:disabled { + background: #440088; + border-color: #440088; + opacity: 0.5; + cursor: not-allowed; +} + /* Show touch controls on mobile only */ @media (max-width: 768px) and (hover: none) { #touch-controls { diff --git a/public/index.html b/public/index.html index c0634a9..f96703e 100644 --- a/public/index.html +++ b/public/index.html @@ -10,6 +10,9 @@ + + +
@@ -48,6 +51,7 @@ +
@@ -62,10 +66,11 @@ + - - - + + + diff --git a/public/js/app.js b/public/js/app.js index 620dc4a..bb19847 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -4,6 +4,17 @@ let localGame = null; let renderer = null; let lastTime = 0; +// Keyboard state for continuous movement (DAS - Delayed Auto Shift) +const keyState = { + ArrowLeft: false, + ArrowRight: false, + ArrowDown: false, +}; + +let dasCounter = 0; +const DAS_DELAY = 100; // ms before auto-repeat starts +const ARR_SPEED = 30; // ms between auto-repeat moves + // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', () => { // Connect to server @@ -26,10 +37,18 @@ function setupNetworkListeners() { // Show lobby screen if we're on the login screen if (ui.screens.room.classList.contains('active')) { ui.showScreen('lobby'); + // Play lobby music when first entering lobby + if (typeof audio !== 'undefined') { + audio.playLobby(); + } } // Reset lobby state when returning from game over (player is null indicates game over reset) if (!player) { ui.resetLobbyState(); + // Resume lobby music after game ends + if (typeof audio !== 'undefined') { + audio.playLobby(); + } } }); @@ -43,6 +62,11 @@ function setupNetworkListeners() { ui.showScreen('game'); ui.displays.gameRoomName.textContent = 'GLOBAL LOBBY'; + // Start game music - will play continuously until game ends + if (typeof audio !== 'undefined') { + audio.playGame(); + } + // Clear old boards renderer.clearAll(); @@ -88,6 +112,11 @@ function setupNetworkListeners() { ui.showScreen('game'); ui.showSpectatorMode(); + // Start game music for spectators too + if (typeof audio !== 'undefined') { + audio.playGame(); + } + // Clear old boards renderer.clearAll(); @@ -122,19 +151,16 @@ function setupKeyboardControls() { if (network.isSpectator) return; if (ui.screens.game.classList.contains('active') && localGame) { + // Track held keys for movement + if (keyState.hasOwnProperty(e.key)) { + keyState[e.key] = true; + dasCounter = 0; // Reset DAS counter when key pressed + e.preventDefault(); + return; + } + + // One-time actions 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(); @@ -147,10 +173,22 @@ function setupKeyboardControls() { network.sendHold(); e.preventDefault(); break; + case 'a': + case 'A': + network.sendZoneActivate(); + e.preventDefault(); + break; } } }); + document.addEventListener('keyup', (e) => { + if (keyState.hasOwnProperty(e.key)) { + keyState[e.key] = false; + dasCounter = 0; + } + }); + // Touch controls for mobile const btnLeft = document.getElementById('btn-left'); const btnRight = document.getElementById('btn-right'); @@ -158,6 +196,7 @@ function setupKeyboardControls() { const btnRotate = document.getElementById('btn-rotate'); const btnDrop = document.getElementById('btn-drop'); const btnHold = document.getElementById('btn-hold'); + const btnZone = document.getElementById('btn-zone'); const handleTouch = (e, action) => { e.preventDefault(); @@ -176,6 +215,7 @@ function setupKeyboardControls() { btnRotate.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendRotate())); btnDrop.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHardDrop())); btnHold.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendHold())); + btnZone.addEventListener('pointerdown', (e) => handleTouch(e, () => network.sendZoneActivate())); // Prevent double-tap zoom btnLeft.addEventListener('touchstart', (e) => e.preventDefault()); @@ -184,6 +224,7 @@ function setupKeyboardControls() { btnRotate.addEventListener('touchstart', (e) => e.preventDefault()); btnDrop.addEventListener('touchstart', (e) => e.preventDefault()); btnHold.addEventListener('touchstart', (e) => e.preventDefault()); + btnZone.addEventListener('touchstart', (e) => e.preventDefault()); } function updateBattleGridLayout(playerCount) { @@ -224,6 +265,8 @@ function endGame(states) { } }); + // Game music will continue during game over screen, then transition back to lobby music via resetLobbyState + // Reset spectator mode for next round network.isSpectator = false; ui.hideSpectatorMode(); @@ -239,6 +282,21 @@ function gameLoop(currentTime) { const deltaTime = currentTime - lastTime; lastTime = currentTime; + // Handle DAS (Delayed Auto Shift) for continuous movement + if (!network.isSpectator && (keyState.ArrowLeft || keyState.ArrowRight || keyState.ArrowDown)) { + dasCounter += deltaTime; + + // Send initial move immediately, then auto-repeat after DAS_DELAY + if (dasCounter >= DAS_DELAY) { + const autoRepeatCounter = (dasCounter - DAS_DELAY) % ARR_SPEED; + if (autoRepeatCounter < (deltaTime || 0)) { + if (keyState.ArrowLeft) network.sendMove('left'); + if (keyState.ArrowRight) network.sendMove('right'); + if (keyState.ArrowDown) network.sendDrop(); + } + } + } + // Update local game if (localGame) { localGame.update(deltaTime); @@ -250,6 +308,13 @@ function gameLoop(currentTime) { renderer.renderPlayer(state.playerId, state); }); + // Update Zone button state for current player + const btnZone = document.getElementById('btn-zone'); + const localState = allStates[network.currentPlayerId]; + if (btnZone && localState) { + btnZone.disabled = localState.zoneMeter < 100 || localState.zoneActive; + } + // Set active player highlight (or null for spectators to show all boards equally) renderer.setActivePlayer(network.isSpectator ? null : network.currentPlayerId); diff --git a/public/js/audio.js b/public/js/audio.js new file mode 100644 index 0000000..08474ed --- /dev/null +++ b/public/js/audio.js @@ -0,0 +1,34 @@ +// Audio Manager - Handles background music only + +class AudioManager { + constructor() { + this.bgMusic = document.getElementById('bg-music'); + if (this.bgMusic) { + this.bgMusic.volume = 0.5; + } + } + + playLobby() { + // No lobby music + } + + playGame() { + if (!this.bgMusic) return; + this.bgMusic.src = '/audio/tetris_theme_og.mp3'; + this.bgMusic.loop = true; + this.bgMusic.play().catch(e => console.log('Audio autoplay blocked:', e)); + } + + playGameOver() { + // No game over music + } + + stop() { + if (!this.bgMusic) return; + this.bgMusic.pause(); + this.bgMusic.currentTime = 0; + } +} + +// Initialize audio manager +const audio = new AudioManager(); diff --git a/public/js/network.js b/public/js/network.js index 0a866be..c3f3004 100644 --- a/public/js/network.js +++ b/public/js/network.js @@ -140,6 +140,11 @@ class NetworkManager { this.socket.emit('player-hold', { playerId: this.currentPlayerId }); } + sendZoneActivate() { + if (!this.socket || !this.currentPlayerId) return; + this.socket.emit('zone-activate', { playerId: this.currentPlayerId }); + } + updatePlayers(players) { this.players = {}; players.forEach(p => { diff --git a/public/js/renderer.js b/public/js/renderer.js index e010360..a763ac2 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -35,10 +35,10 @@ class TetrisRenderer { 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'; + nextCanvas.width = 60; + nextCanvas.height = 60; + nextCanvas.style.width = '60px'; + nextCanvas.style.height = '60px'; nextDiv.innerHTML = 'NEXT:'; nextDiv.appendChild(nextCanvas); boardDiv.appendChild(nextDiv); @@ -48,14 +48,21 @@ class TetrisRenderer { 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'; + holdCanvas.width = 60; + holdCanvas.height = 60; + holdCanvas.style.width = '60px'; + holdCanvas.style.height = '60px'; holdDiv.innerHTML = 'HOLD:'; holdDiv.appendChild(holdCanvas); boardDiv.appendChild(holdDiv); + // Zone meter + const zoneDiv = document.createElement('div'); + zoneDiv.className = 'board-info zone-info'; + zoneDiv.id = `zone-${playerId}`; + zoneDiv.innerHTML = 'ZONE: '; + boardDiv.appendChild(zoneDiv); + this.container.appendChild(boardDiv); this.boards.set(playerId, { @@ -63,6 +70,7 @@ class TetrisRenderer { canvas: canvas, nextCanvas: nextCanvas, holdCanvas: holdCanvas, + zoneDiv: zoneDiv, info: infoDiv, ctx: canvas.getContext('2d'), nextCtx: nextCanvas.getContext('2d'), @@ -128,6 +136,28 @@ class TetrisRenderer { if (linesSpan) { linesSpan.textContent = gameState.lines; } + + // Update Zone meter + const zoneDiv = boardData.zoneDiv; + if (zoneDiv) { + const meterFill = zoneDiv.querySelector('.zone-meter-fill'); + const statusSpan = zoneDiv.querySelector('.zone-status'); + if (meterFill) { + meterFill.style.width = gameState.zoneMeter + '%'; + } + if (statusSpan) { + if (gameState.zoneActive) { + statusSpan.textContent = `ACTIVE (${Math.max(0, Math.round(gameState.zoneTimeRemaining / 1000))}s)`; + statusSpan.style.color = '#00ff00'; + } else if (gameState.zoneMeter >= 100) { + statusSpan.textContent = 'READY!'; + statusSpan.style.color = '#ffff00'; + } else { + statusSpan.textContent = ''; + statusSpan.style.color = '#fff'; + } + } + } } // Update board state classes (preserving main/spectator/position classes) diff --git a/public/js/ui.js b/public/js/ui.js index b23bbe5..6ceb412 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -48,6 +48,11 @@ class UIManager { showScreen(screenName) { Object.values(this.screens).forEach(screen => screen.classList.remove('active')); this.screens[screenName].classList.add('active'); + + // Play audio based on screen (but not for game/lobby - those are handled by app.js) + if (typeof audio !== 'undefined' && screenName === 'room') { + audio.stop(); + } } handleJoin() { diff --git a/server/index.js b/server/index.js index bde5d44..40b9400 100644 --- a/server/index.js +++ b/server/index.js @@ -55,6 +55,11 @@ app.use(express.static(path.join(__dirname, '../public'))); * @property {number} dropCounter - Frame counter for auto-drop * @property {number} dropInterval - Milliseconds between auto-drops * @property {object[]} garbageReceived - History of garbage received + * @property {number} zoneMeter - Zone meter (0-100), increases by line clears + * @property {boolean} zoneActive - Whether Zone is currently active + * @property {number} zoneStartTime - Timestamp when Zone started + * @property {number} zoneLineCount - Lines cleared during current Zone + * @property {number} zoneTotalDuration - Total Zone duration in ms (20000ms = 20 seconds) */ /** @@ -138,7 +143,12 @@ io.on('connection', (socket) => { ready: false, dropCounter: 0, dropInterval: 1000, - garbageReceived: [] + garbageReceived: [], + zoneMeter: 0, + zoneActive: false, + zoneStartTime: null, + zoneLineCount: 0, + zoneTotalDuration: 20000 }; lobby.players.set(socket.id, player); @@ -328,6 +338,24 @@ io.on('connection', (socket) => { broadcastState(); }); + socket.on('zone-activate', ({ playerId }) => { + if (!lobby.gameStarted) return; + + const player = lobby.players.get(playerId); + if (!player || player.eliminated || player.zoneActive) return; + + // Only activate if Zone meter is full + if (player.zoneMeter < 100) return; + + player.zoneActive = true; + player.zoneStartTime = Date.now(); + player.zoneLineCount = 0; + player.zoneMeter = 0; + + console.log(`[ZONE] ${player.name} activated Zone!`); + broadcastState(); + }); + socket.on('disconnect', () => { const player = lobby.players.get(socket.id); if (player) { @@ -464,7 +492,22 @@ function lockPiece(player) { function clearRows(player) { const result = clearRowsFromBoard(player.board); - const rowsCleared = result.rowsCleared; + let rowsCleared = result.rowsCleared; + + // During Zone, lines cascade to bottom instead of disappearing - reapply them + if (player.zoneActive && rowsCleared > 0) { + player.zoneLineCount += rowsCleared; + // Keep the lines on board for cascade effect (they stay, don't disappear) + player.board = result.board; + // Add cleared lines back to bottom for visual cascade + for (let i = 0; i < rowsCleared; i++) { + player.board.push(Array(BOARD_WIDTH).fill('#999999')); // Cleared line color + } + // Shift other lines up to make room + player.board = player.board.slice(-BOARD_HEIGHT); + return rowsCleared; // Still count for scoring + } + player.board = result.board; if (rowsCleared > 0) { @@ -473,7 +516,12 @@ function clearRows(player) { player.score += points[rowsCleared] * player.level; player.level = Math.floor(player.lines / 10) + 1; player.dropInterval = Math.max(100, 1000 - (player.level - 1) * 100); + + // Fill Zone meter: different amounts based on lines cleared + const zoneMeterIncrease = [0, 15, 25, 35, 50][Math.min(4, rowsCleared)] || 0; + player.zoneMeter = Math.min(100, player.zoneMeter + zoneMeterIncrease); } + return rowsCleared; } @@ -498,6 +546,27 @@ function sendGarbage(sender, rowsCleared) { } } +function sendZoneAttack(sender, zoneLineCount) { + // Zone attack sends bonus garbage based on lines cleared during Zone + // Formula: lines * 1.5 (rounded) = garbage rows + // Example: 4 Zone lines = 6 garbage rows, 8 Zone lines = 12 garbage rows + const totalGarbageLines = Math.round(zoneLineCount * 1.5); + + const garbageLog = []; + for (let i = 0; i < totalGarbageLines; i++) { + const opponents = Array.from(lobby.players.values()).filter(p => p.id !== sender.id && !p.eliminated); + if (opponents.length === 0) break; + + const target = opponents[Math.floor(Math.random() * opponents.length)]; + garbageLog.push(target.name); + addGarbageToPlayer(target, `${sender.name} (ZONE)`); + } + + if (garbageLog.length > 0) { + console.log(`[ZONE ATTACK] ${sender.name} sent ${totalGarbageLines} garbage rows from ${zoneLineCount} Zone lines -> targets: ${garbageLog.join(', ')}`); + } +} + function addGarbageToPlayer(player, senderName) { // Use the pure function to add garbage row const result = addGarbageRow(player.board, player.currentPiece); @@ -528,6 +597,10 @@ function startGame() { player.garbageReceived = []; player.currentPiece = null; player.nextPiece = null; + player.zoneMeter = 0; + player.zoneActive = false; + player.zoneStartTime = null; + player.zoneLineCount = 0; lobby.playerSequenceIndex.set(player.id, 0); } @@ -556,6 +629,25 @@ function gameTick() { for (const player of lobby.players.values()) { if (player.eliminated || !player.currentPiece) continue; + // Handle Zone time management + if (player.zoneActive) { + const elapsedTime = Date.now() - player.zoneStartTime; + if (elapsedTime >= player.zoneTotalDuration) { + // Zone ended - convert zone lines to attack + player.zoneActive = false; + console.log(`[ZONE END] ${player.name} cleared ${player.zoneLineCount} lines during Zone`); + if (player.zoneLineCount > 0) { + sendZoneAttack(player, player.zoneLineCount); + } + player.zoneLineCount = 0; + } else { + // During Zone - gravity doesn't apply, let pieces sort themselves out + // Players still control manually, pieces still lock after a delay if touching stack + continue; + } + } + + // Normal gravity (not in Zone) player.dropCounter += 50; if (player.dropCounter >= player.dropInterval) { player.dropCounter = 0; @@ -588,7 +680,11 @@ function getStates() { level: p.level, eliminated: p.eliminated, sequenceIndex: lobby.playerSequenceIndex.get(p.id) || 0, - garbageReceived: p.garbageReceived ? JSON.parse(JSON.stringify(p.garbageReceived)) : [] + garbageReceived: p.garbageReceived ? JSON.parse(JSON.stringify(p.garbageReceived)) : [], + zoneMeter: p.zoneMeter, + zoneActive: p.zoneActive, + zoneTimeRemaining: p.zoneActive ? Math.max(0, p.zoneTotalDuration - (Date.now() - p.zoneStartTime)) : 0, + zoneLineCount: p.zoneLineCount })); } diff --git a/server/package-lock.json b/server/package-lock.json index c9bfe4e..73e54e1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -45,6 +45,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1306,6 +1307,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759",