Add performance optimizations for mobile devices

This commit is contained in:
cmclark00 2025-03-25 17:35:34 -04:00
parent 324c977b3b
commit 2b7c5175a0

789
script.js
View file

@ -1,3 +1,17 @@
// Performance optimization variables
let lastTimestamp = 0;
const FPS_LIMIT = 60; // Default FPS limit
const MOBILE_FPS_LIMIT = 30; // Lower FPS on mobile
const FRAME_MIN_TIME = (1000 / FPS_LIMIT);
let isReducedEffects = false; // For mobile performance
let maxFireworks = 30; // Default limit
let maxParticlesPerFirework = 30; // Default limit
// Add performance monitoring
let frameCounter = 0;
let lastFpsUpdate = 0;
let currentFps = 0;
// Get canvas and context
const canvas = document.getElementById('tetris');
const ctx = canvas.getContext('2d');
@ -311,7 +325,19 @@ for (let i = 0; i < PIECES.length; i++) {
PIECE_3D_ORIENTATIONS[i].push(verticalRotation);
}
// Firework class for visual effects
// Add this to the loadOptions function
function loadOptions() {
// ... existing code ...
// Detect if we're on mobile and reduce effects automatically
if (isMobile) {
maxFireworks = 10;
maxParticlesPerFirework = 15;
isReducedEffects = true;
}
}
// Modify the Firework constructor to use the max particles limit
class Firework {
constructor(x, y) {
this.x = x;
@ -322,8 +348,13 @@ class Firework {
this.isDone = false;
this.colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
// Create particles
for (let i = 0; i < this.particleCount; i++) {
// Limit particles based on device capability
const particleCount = isReducedEffects ?
Math.floor(Math.random() * 10) + 5 :
Math.floor(Math.random() * maxParticlesPerFirework) + 10;
// Create particles with limit
for (let i = 0; i < particleCount; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 3 + 2;
const size = Math.random() * 3 + 1;
@ -1569,31 +1600,64 @@ function playLineClearSound(lineCount) {
// Update game frame
function update() {
// Update and draw fireworks
for (let i = fireworks.length - 1; i >= 0; i--) {
fireworks[i].update();
// Remove old fireworks
fireworks = fireworks.filter(fw => !fw.done);
// Only create new fireworks if we're below the limit and not on low-end devices
if (!gameOver && !paused) {
const maxAllowed = isReducedEffects ? maxFireworks/2 : maxFireworks;
const creationProbability = isReducedEffects ? 0.005 : 0.02; // Lower probability on mobile
if (fireworks[i].isDone) {
fireworks.splice(i, 1);
if (fireworks.length < maxAllowed && Math.random() < creationProbability) {
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
fireworks.push(new Firework(x, y));
}
// Update active fireworks - only if not too many
const fireLimit = Math.min(fireworks.length, isReducedEffects ? 10 : fireworks.length);
for (let i = 0; i < fireLimit; i++) {
fireworks[i].update();
}
}
// Request the next frame
// Continue the animation
requestAnimationFrame(update);
}
// Draw game elements
// Draw game elements with frame limiting
function draw() {
// Clear the entire canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gameOver || paused) return;
// Draw a border around the play area
ctx.strokeStyle = 'rgba(80, 80, 80, 0.6)';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, canvas.width, canvas.height);
// Frame rate limiting
const now = performance.now();
const elapsed = now - lastTimestamp;
// Clear any previous piece positions
clearPreviousPiecePosition();
// FPS monitoring
frameCounter++;
if (now - lastFpsUpdate > 1000) {
currentFps = frameCounter;
frameCounter = 0;
lastFpsUpdate = now;
// Log FPS every second for debugging
if (isReducedEffects) {
console.log(`Current FPS: ${currentFps}`);
}
}
// Skip frames to maintain target FPS
const frameTime = isMobile ? (1000 / MOBILE_FPS_LIMIT) : FRAME_MIN_TIME;
if (elapsed < frameTime) {
requestAnimationFrame(draw);
return;
}
lastTimestamp = now - (elapsed % frameTime);
// Only clear what's needed
ctx.fillStyle = EMPTY;
ctx.fillRect(0, 0, COLS * BLOCK_SIZE, ROWS * BLOCK_SIZE);
// Draw the board - this shows only locked pieces
drawBoard();
@ -1603,17 +1667,23 @@ function draw() {
p.draw();
}
// Draw fireworks (clipped to canvas area)
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.clip();
for (let i = 0; i < fireworks.length; i++) {
fireworks[i].draw();
// On mobile, render fireworks less frequently
if (!isMobile || frameCounter % 2 === 0) {
// Draw fireworks (with clipping for performance)
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.clip();
// Limit how many fireworks we draw
const fireworkLimit = isReducedEffects ? 5 : fireworks.length;
for (let i = 0; i < Math.min(fireworkLimit, fireworks.length); i++) {
fireworks[i].draw();
}
ctx.restore();
}
ctx.restore();
// Draw messages if game is paused
// Draw paused message if needed
if (paused) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
@ -1623,609 +1693,13 @@ function draw() {
ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2);
}
// Request the next frame
requestAnimationFrame(draw);
}
// Drop the piece - called by interval
function dropPiece() {
if (gameOver || paused) return;
let now = Date.now();
let delta = now - dropStart;
// Drop speed depends on level
let speed = 1000 * (1 - (level - 1) * 0.1);
speed = Math.max(speed, 100); // Don't allow too fast drops (minimum 100ms)
if (delta > speed) {
p.moveDown();
dropStart = now;
// If we can't move down and we're still at the top, game over
if (p.collision(0, 1) && p.y < 1) {
gameOver = true;
showGameOver();
}
}
if (!gameOver) requestAnimationFrame(draw);
}
// Optimize touch events handling to avoid excessive processing
// Add debounce for touch events
let touchMoveDebounce = false;
// Show game over modal
function showGameOver() {
clearInterval(gameInterval);
finalScoreElement.textContent = score;
gameOverModal.classList.add('active');
}
// Reset the game
function resetGame() {
// Reset game variables
score = 0;
level = 1;
lines = 0;
gameOver = false;
paused = false;
// Clear the board
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
board[r][c] = EMPTY;
}
}
// Update UI
scoreElement.textContent = score;
levelElement.textContent = level;
linesElement.textContent = lines;
// Close game over modal if open
gameOverModal.classList.remove('active');
// Generate pieces
p = randomPiece();
nextPiece = randomPiece();
nextPiece.drawNextPiece();
// Start the game interval
dropStart = Date.now();
clearInterval(gameInterval);
gameInterval = setInterval(dropPiece, 1000);
}
// Toggle pause
function togglePause() {
paused = !paused;
if (paused) {
clearInterval(gameInterval);
pauseBtn.textContent = "Resume";
} else {
gameInterval = setInterval(dropPiece, Math.max(100, 1000 - (level * 100)));
pauseBtn.textContent = "Pause";
}
}
// Toggle options modal
function toggleOptionsModal() {
if (optionsModal.classList.contains('active')) {
optionsModal.classList.remove('active');
} else {
optionsModal.classList.add('active');
}
}
// Apply options settings
function applyOptions() {
enable3DEffects = toggle3DEffects.checked;
enableSpinAnimations = toggleSpinAnimations.checked;
animationSpeed = parseFloat(animationSpeedSlider.value);
forceMobileControls = toggleMobileControls.checked;
// Apply mobile controls if checked or if on mobile device
if (forceMobileControls) {
if (!touchControls) {
initTouchControls();
touchControls = true;
}
document.body.classList.add('mobile-mode');
} else if (!isMobile) {
document.body.classList.remove('mobile-mode');
}
// Save options
saveOptions();
}
// Save options to localStorage
function saveOptions() {
localStorage.setItem('tetris3DOptions', JSON.stringify({
enable3DEffects,
enableSpinAnimations,
animationSpeed,
forceMobileControls
}));
}
// Load options from localStorage
function loadOptions() {
const savedOptions = localStorage.getItem('tetris3DOptions');
if (savedOptions) {
const options = JSON.parse(savedOptions);
enable3DEffects = options.enable3DEffects !== undefined ? options.enable3DEffects : true;
enableSpinAnimations = options.enableSpinAnimations !== undefined ? options.enableSpinAnimations : true;
animationSpeed = options.animationSpeed !== undefined ? options.animationSpeed : 0.05;
forceMobileControls = options.forceMobileControls !== undefined ? options.forceMobileControls : false;
// Update UI controls
toggle3DEffects.checked = enable3DEffects;
toggleSpinAnimations.checked = enableSpinAnimations;
animationSpeedSlider.value = animationSpeed;
if (toggleMobileControls) {
toggleMobileControls.checked = forceMobileControls;
}
}
}
// Initialize game
function init() {
// Reset canvas to ensure clean state
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Load saved options
loadOptions();
// Draw the board
drawBoard();
// Generate initial pieces
p = randomPiece();
nextPiece = randomPiece();
// Draw initial next piece
nextPiece.drawNextPiece();
// Set up game interval
dropStart = Date.now();
gameInterval = setInterval(dropPiece, 1000);
// Listen for keyboard events
document.addEventListener('keydown', control);
// Add resize listener for responsive layout
window.addEventListener('resize', handleResize);
// Initial resize to set correct dimensions
handleResize();
// Button event listeners
startBtn.addEventListener('click', resetGame);
pauseBtn.addEventListener('click', togglePause);
playAgainBtn.addEventListener('click', resetGame);
shadowBtn.addEventListener('click', toggleShadow);
optionsBtn.addEventListener('click', toggleOptionsModal);
optionsCloseBtn.addEventListener('click', toggleOptionsModal);
// Options event listeners
toggle3DEffects.addEventListener('change', function() {
applyOptions();
});
toggleSpinAnimations.addEventListener('change', function() {
applyOptions();
});
animationSpeedSlider.addEventListener('input', function() {
applyOptions();
});
if (toggleMobileControls) {
toggleMobileControls.addEventListener('change', function() {
applyOptions();
// Force resize to update layout
window.dispatchEvent(new Event('resize'));
});
}
// Apply mobile mode if forced or on mobile device
if (forceMobileControls || isMobile) {
if (!touchControls) {
initTouchControls();
touchControls = true;
}
document.body.classList.add('mobile-mode');
}
// Initialize controller support
initControllerSupport();
// Initial controller status update
updateControllerStatus();
}
// Handle window resize for responsive layout
function handleResize() {
const gameContainer = document.querySelector('.game-container');
const gameWrapper = document.querySelector('.game-wrapper');
const scoreContainer = document.querySelector('.score-container');
if (isMobile || forceMobileControls) {
// Scale the canvas to fit mobile screen
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);
const viewportHeight = Math.min(window.innerHeight, document.documentElement.clientHeight);
// Detect orientation
const isPortrait = viewportHeight > viewportWidth;
// Calculate available game area (accounting for UI elements)
const titleHeight = 40; // Estimate for title
const scoreWidth = isPortrait ? 120 : 100; // Width for score container in portrait/landscape
const availableWidth = viewportWidth - scoreWidth - 20; // Subtract score width + padding
const availableHeight = viewportHeight - titleHeight - 20; // Subtract title height + padding
// Calculate optimal dimensions while maintaining aspect ratio
const gameRatio = ROWS / COLS;
// Calculate scale based on available space
let targetWidth, targetHeight;
if (isPortrait) {
// For portrait, prioritize fitting the width
targetWidth = availableWidth * 0.95;
targetHeight = targetWidth * gameRatio;
// If too tall, scale down based on height
if (targetHeight > availableHeight * 0.95) {
targetHeight = availableHeight * 0.95;
targetWidth = targetHeight / gameRatio;
}
} else {
// For landscape, prioritize fitting the height
targetHeight = availableHeight * 0.95;
targetWidth = targetHeight / gameRatio;
// If too wide, scale down based on width
if (targetWidth > availableWidth * 0.95) {
targetWidth = availableWidth * 0.95;
targetHeight = targetWidth * gameRatio;
}
}
// Apply dimensions
canvas.style.width = `${targetWidth}px`;
canvas.style.height = `${targetHeight}px`;
// Force a redraw of the nextPiece preview to fix rendering issues
if (nextPiece) {
setTimeout(() => {
nextPieceCtx.clearRect(0, 0, nextPieceCanvas.width, nextPieceCanvas.height);
nextPiece.drawNextPiece();
}, 100);
}
// Show touch controls mode
document.body.classList.add('mobile-mode');
} else {
// Reset to desktop layout
canvas.style.width = '';
canvas.style.height = '';
document.body.classList.remove('mobile-mode');
}
}
// Toggle shadow display
function toggleShadow() {
showShadow = !showShadow;
if (!gameOver && !paused) {
// Redraw current piece to show/hide shadow
p.undraw();
p.draw();
}
}
// Controller support
function initControllerSupport() {
// Check for existing gamepads
scanGamepads();
// Listen for controller connection/disconnection
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id);
gamepadConnected = true;
controllers[e.gamepad.index] = e.gamepad;
// Start polling for controller input if not already
if (!controllerInterval) {
controllerInterval = setInterval(pollControllers, controllerPollingRate);
}
// Show controller connected message
showControllerMessage(`Controller connected: ${e.gamepad.id}`);
});
window.addEventListener('gamepaddisconnected', (e) => {
console.log('Gamepad disconnected:', e.gamepad.id);
delete controllers[e.gamepad.index];
// Check if there are any controllers left
if (Object.keys(controllers).length === 0) {
gamepadConnected = false;
clearInterval(controllerInterval);
controllerInterval = null;
}
// Show controller disconnected message
showControllerMessage('Controller disconnected');
});
// Initial scan
if (Object.keys(controllers).length > 0) {
controllerInterval = setInterval(pollControllers, controllerPollingRate);
}
}
// Scan for connected gamepads
function scanGamepads() {
const gamepads = navigator.getGamepads ? navigator.getGamepads() :
(navigator.webkitGetGamepads ? navigator.webkitGetGamepads() : []);
for (let i = 0; i < gamepads.length; i++) {
if (gamepads[i]) {
controllers[gamepads[i].index] = gamepads[i];
gamepadConnected = true;
}
}
}
// Poll controllers for input
function pollControllers() {
if (!gamepadConnected || gameOver) return;
// Get fresh gamepad data
scanGamepads();
// Use the first available controller
const controller = controllers[Object.keys(controllers)[0]];
if (!controller) return;
// Initialize state object if needed
if (!lastControllerState[controller.index]) {
lastControllerState[controller.index] = {
buttons: Array(controller.buttons.length).fill(false),
axes: Array(controller.axes.length).fill(0)
};
}
// Check buttons
for (let action in controllerMapping) {
const buttonIndex = controllerMapping[action][0];
// Handle button presses
if (buttonIndex >= 0 && buttonIndex < controller.buttons.length) {
const button = controller.buttons[buttonIndex];
const pressed = button.pressed || button.value > 0.5;
// Check if this is a new press (wasn't pressed last time)
if (pressed && !lastControllerState[controller.index].buttons[buttonIndex]) {
// Handle controller action
handleControllerAction(action);
}
// Update state
lastControllerState[controller.index].buttons[buttonIndex] = pressed;
}
}
// Handle analog stick for movement
// Left stick horizontal
if (controller.axes[0] < -0.5 && lastControllerState[controller.index].axes[0] >= -0.5) {
handleControllerAction('left');
}
else if (controller.axes[0] > 0.5 && lastControllerState[controller.index].axes[0] <= 0.5) {
handleControllerAction('right');
}
// Left stick vertical
if (controller.axes[1] > 0.5 && lastControllerState[controller.index].axes[1] <= 0.5) {
handleControllerAction('down');
}
// Update axes state
lastControllerState[controller.index].axes = [...controller.axes];
}
// Handle controller actions
function handleControllerAction(action) {
if (paused && action !== 'pause') return;
switch(action) {
case 'left':
p.moveLeft();
break;
case 'right':
p.moveRight();
break;
case 'down':
p.moveDown();
break;
case 'rotateLeft':
p.rotate('left');
break;
case 'rotateRight':
p.rotate('right');
break;
case 'mirrorH':
p.mirrorHorizontal();
break;
case 'mirrorV':
p.mirrorVertical();
break;
case 'hardDrop':
p.hardDrop();
break;
case 'pause':
togglePause();
break;
}
}
// Show controller message
function showControllerMessage(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'controller-message';
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
// Remove after a delay
setTimeout(() => {
messageDiv.classList.add('fade-out');
setTimeout(() => {
document.body.removeChild(messageDiv);
}, 500);
}, 3000);
// Update controller status indicator
updateControllerStatus();
}
// Update controller status indicator
function updateControllerStatus() {
if (gamepadConnected && Object.keys(controllers).length > 0) {
const controller = controllers[Object.keys(controllers)[0]];
controllerStatus.textContent = `Controller Connected: ${controller.id.slice(0, 20)}...`;
controllerStatus.classList.remove('disconnected');
controllerStatus.classList.add('connected');
} else {
controllerStatus.textContent = 'No Controller Detected';
controllerStatus.classList.remove('connected');
controllerStatus.classList.add('disconnected');
}
}
// The control function
function control(event) {
if (gameOver) return;
if (paused && event.keyCode !== 80) return; // Allow only P key if paused
switch(event.keyCode) {
case 37: // Left arrow
case 65: // A key
p.moveLeft();
break;
case 38: // Up arrow - no function
break;
case 39: // Right arrow
case 68: // D key
p.moveRight();
break;
case 40: // Down arrow
case 83: // S key
p.moveDown();
break;
case 81: // Q key - rotate left
p.rotate('left');
break;
case 69: // E key - rotate right
p.rotate('right');
break;
case 87: // W key - 3D vertical rotation
p.rotate3DX();
break;
case 88: // X key - 3D horizontal rotation
p.rotate3DY();
break;
case 32: // Space bar - hard drop
p.hardDrop();
break;
case 80: // P key - pause
togglePause();
break;
case 72: // H key - toggle shadow
toggleShadow();
break;
}
}
// Maintain a reference to the active piece's previous position for proper clearing
let previousPiecePosition = {
x: 0,
y: 0,
shape: null,
exists: false
};
// Always clear previous piece position before drawing new position
function clearPreviousPiecePosition() {
if (previousPiecePosition.exists && previousPiecePosition.shape) {
// Clear entire previous area with padding
const padding = 5;
const minX = Math.max(0, previousPiecePosition.x - padding);
const maxX = Math.min(COLS - 1, previousPiecePosition.x + previousPiecePosition.shape[0].length + padding);
const minY = Math.max(0, previousPiecePosition.y - padding);
const maxY = Math.min(ROWS - 1, previousPiecePosition.y + previousPiecePosition.shape.length + padding);
for (let r = minY; r <= maxY; r++) {
for (let c = minX; c <= maxX; c++) {
if (r >= 0 && c >= 0 && r < ROWS && c < COLS && board[r][c] === EMPTY) {
drawSquare(c, r, EMPTY);
}
}
}
}
}
// Touch control variables
let touchStartX = 0;
let touchStartY = 0;
let touchStartTime = 0;
const SWIPE_THRESHOLD = 15; // Reduced threshold for more responsive movement
const TAP_THRESHOLD = 200; // milliseconds
const DOUBLE_TAP_THRESHOLD = 300; // Reduced from 400 to make double-tap easier
const TRIPLE_TAP_THRESHOLD = 600; // Maximum time between first and third tap
let lastTapTime = 0;
let secondLastTapTime = 0; // Track time of second-to-last tap for triple tap detection
let lastMoveTime = 0;
let touchIdentifier = null;
let lastTapX = 0;
let lastTapY = 0;
let secondLastTapX = 0; // Track position of second-to-last tap
let secondLastTapY = 0; // Track position of second-to-last tap
const TAP_DISTANCE_THRESHOLD = 40; // Increased from 20 to be more forgiving for double-taps
const MOVE_COOLDOWN = 60; // Cooldown between moves to prevent too rapid movement
// Initialize touch controls
function initTouchControls() {
// Add touch event listeners to the canvas
canvas.addEventListener('touchstart', handleTouchStart, false);
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd, false);
// Create on-screen control buttons
createTouchControlButtons();
}
// Handle touch start event
function handleTouchStart(event) {
if (gameOver || paused) return;
event.preventDefault();
// Only track single touches for gesture detection
if (event.touches.length > 1) return;
// Store the initial touch position
const touch = event.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartTime = Date.now();
touchIdentifier = touch.identifier;
}
// Handle touch move event
function handleTouchMove(event) {
if (gameOver || paused) return;
event.preventDefault();
@ -2233,6 +1707,11 @@ function handleTouchMove(event) {
// Skip if it's a multi-touch gesture
if (event.touches.length > 1) return;
// Add debounce to reduce excessive processing
if (touchMoveDebounce) return;
touchMoveDebounce = true;
setTimeout(() => { touchMoveDebounce = false; }, 16); // ~60fps
const now = Date.now();
if (!event.touches.length) return;
@ -2272,6 +1751,22 @@ function handleTouchMove(event) {
}
}
// Handle touch start event
function handleTouchStart(event) {
if (gameOver || paused) return;
event.preventDefault();
// Only track single touches for gesture detection
if (event.touches.length > 1) return;
// Store the initial touch position
const touch = event.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartTime = Date.now();
touchIdentifier = touch.identifier;
}
// Handle touch end event
function handleTouchEnd(event) {
if (gameOver || paused) return;
@ -2504,7 +1999,13 @@ function getNextPieceFromBag() {
// Start the game
window.onload = function() {
// Initialize the game
init();
// Apply mobile optimizations
optimizeForMobile();
// Start animation loops
update(); // Start the fireworks update loop
draw(); // Start the drawing loop
@ -2516,4 +2017,38 @@ window.onload = function() {
// Force resize to ensure proper mobile layout
window.dispatchEvent(new Event('resize'));
}
};
};
// Add a mobile-specific optimization function to call on game initialization
function optimizeForMobile() {
if (isMobile) {
// Reduce effects
isReducedEffects = true;
maxFireworks = 5;
maxParticlesPerFirework = 10;
// Use lower frame rate for mobile
const FRAME_MIN_TIME = (1000 / MOBILE_FPS_LIMIT);
// Reduce shadow complexity
showShadow = false; // Start with shadow off on mobile for performance
console.log("Applying mobile optimizations");
}
}
// Add cleanup function to prevent memory leaks
function cleanup() {
// Clear any large arrays that might be causing memory issues
fireworks = fireworks.slice(0, Math.min(fireworks.length, maxFireworks));
// Force garbage collection hints
if (window.gc) window.gc();
console.log("Memory cleanup performed");
}
// Call cleanup periodically on mobile devices
if (isMobile) {
setInterval(cleanup, 60000); // Cleanup every minute
}