Implement DAS/ARR for smooth horizontal piece movement

This commit is contained in:
2026-03-24 17:59:55 -07:00
parent 36965dc887
commit 3a307dbc80
11 changed files with 338 additions and 26 deletions
+77 -12
View File
@@ -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);
+34
View File
@@ -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();
+5
View File
@@ -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 => {
+38 -8
View File
@@ -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 = '<span>NEXT:</span>';
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 = '<span>HOLD:</span>';
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 = '<span>ZONE: <span class="zone-meter-bar"><span class="zone-meter-fill"></span></span> <span class="zone-status"></span></span>';
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)
+5
View File
@@ -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() {