Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # LLM Collaboration
- Date: 2025-03-30 20:12:00
- ## Initial Task
- ```
- Let's create an amazing version of 2048, playable in a browser. Be sure to output one codeblock. Feel free to use any additional libraries you need.
- ```
- ## Claude (Iteration 1)
- # 2048 Game - Enhanced Browser Version
- Here's an enhanced version of the classic 2048 game that's playable in a browser. This implementation includes animations, responsive design, score tracking, and a clean UI.
- ```html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Enhanced 2048</title>
- <style>
- :root {
- --grid-size: 4;
- --cell-size: 100px;
- --cell-gap: 12px;
- --border-radius: 8px;
- }
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: 'Arial', sans-serif;
- }
- body {
- background-color: #faf8ef;
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100vh;
- overflow: hidden;
- touch-action: none;
- }
- .container {
- width: min(95vw, 600px);
- padding: 20px;
- border-radius: 10px;
- background-color: #bbada0;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- }
- .title {
- color: #776e65;
- font-size: 2.5rem;
- font-weight: bold;
- }
- .scores {
- display: flex;
- gap: 10px;
- }
- .score-box {
- background-color: #bbada0;
- color: white;
- padding: 10px 15px;
- border-radius: 5px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 80px;
- background-color: #8f7a66;
- }
- .score-title {
- font-size: 0.8rem;
- text-transform: uppercase;
- }
- .score-value {
- font-size: 1.5rem;
- font-weight: bold;
- }
- .sub-header {
- display: flex;
- justify-content: space-between;
- margin-bottom: 20px;
- }
- .game-intro {
- color: #776e65;
- max-width: 300px;
- font-size: 0.9rem;
- }
- .new-game-btn {
- background-color: #8f7a66;
- color: white;
- border: none;
- border-radius: 5px;
- padding: 10px 15px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.3s;
- }
- .new-game-btn:hover {
- background-color: #9f8b77;
- }
- .game-container {
- position: relative;
- width: calc(var(--cell-size) * var(--grid-size) + var(--cell-gap) * (var(--grid-size) + 1));
- height: calc(var(--cell-size) * var(--grid-size) + var(--cell-gap) * (var(--grid-size) + 1));
- background-color: #bbada0;
- border-radius: var(--border-radius);
- padding: var(--cell-gap);
- margin: 0 auto;
- }
- .cell {
- background-color: rgba(238, 228, 218, 0.35);
- border-radius: var(--border-radius);
- position: absolute;
- width: var(--cell-size);
- height: var(--cell-size);
- }
- .tile {
- position: absolute;
- display: flex;
- justify-content: center;
- align-items: center;
- width: var(--cell-size);
- height: var(--cell-size);
- border-radius: var(--border-radius);
- font-size: 2rem;
- font-weight: bold;
- background-color: #eee4da;
- color: #776e65;
- transition: transform 100ms ease-in-out;
- animation: appear 200ms ease-in-out;
- z-index: 10;
- }
- @keyframes appear {
- 0% {
- opacity: 0;
- transform: scale(0);
- }
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- }
- .tile.merged {
- animation: pop 200ms ease-in-out;
- }
- @keyframes pop {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.2);
- }
- 100% {
- transform: scale(1);
- }
- }
- .tile[data-value="2"] {
- background-color: #eee4da;
- }
- .tile[data-value="4"] {
- background-color: #ede0c8;
- }
- .tile[data-value="8"] {
- background-color: #f2b179;
- color: white;
- }
- .tile[data-value="16"] {
- background-color: #f59563;
- color: white;
- }
- .tile[data-value="32"] {
- background-color: #f67c5f;
- color: white;
- }
- .tile[data-value="64"] {
- background-color: #f65e3b;
- color: white;
- }
- .tile[data-value="128"] {
- background-color: #edcf72;
- color: white;
- font-size: 1.8rem;
- }
- .tile[data-value="256"] {
- background-color: #edcc61;
- color: white;
- font-size: 1.8rem;
- }
- .tile[data-value="512"] {
- background-color: #edc850;
- color: white;
- font-size: 1.8rem;
- }
- .tile[data-value="1024"] {
- background-color: #edc53f;
- color: white;
- font-size: 1.5rem;
- }
- .tile[data-value="2048"] {
- background-color: #edc22e;
- color: white;
- font-size: 1.5rem;
- }
- .game-message {
- display: none;
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- background-color: rgba(238, 228, 218, 0.73);
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- border-radius: var(--border-radius);
- z-index: 100;
- }
- .game-message.game-won, .game-message.game-over {
- display: flex;
- }
- .game-message p {
- font-size: 2rem;
- font-weight: bold;
- color: #776e65;
- margin-bottom: 20px;
- }
- .game-message .lower {
- display: flex;
- gap: 10px;
- margin-top: 20px;
- }
- @media screen and (max-width: 520px) {
- :root {
- --cell-size: 65px;
- --cell-gap: 8px;
- }
- .title {
- font-size: 2rem;
- }
- .score-box {
- min-width: 60px;
- padding: 8px 10px;
- }
- .score-value {
- font-size: 1.2rem;
- }
- .game-intro {
- font-size: 0.8rem;
- max-width: 200px;
- }
- .tile {
- font-size: 1.5rem;
- }
- .tile[data-value="128"],
- .tile[data-value="256"],
- .tile[data-value="512"] {
- font-size: 1.3rem;
- }
- .tile[data-value="1024"],
- .tile[data-value="2048"] {
- font-size: 1.1rem;
- }
- }
- @media screen and (max-width: 340px) {
- :root {
- --cell-size: 55px;
- --cell-gap: 6px;
- }
- .container {
- padding: 10px;
- }
- .header {
- flex-direction: column;
- gap: 10px;
- margin-bottom: 10px;
- }
- .sub-header {
- flex-direction: column;
- gap: 10px;
- align-items: center;
- margin-bottom: 10px;
- }
- .game-intro {
- text-align: center;
- margin-bottom: 10px;
- }
- }
- .footer {
- margin-top: 20px;
- text-align: center;
- color: #776e65;
- font-size: 0.8rem;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1 class="title">2048</h1>
- <div class="scores">
- <div class="score-box">
- <div class="score-title">Score</div>
- <div class="score-value" id="score">0</div>
- </div>
- <div class="score-box">
- <div class="score-title">Best</div>
- <div class="score-value" id="best-score">0</div>
- </div>
- </div>
- </div>
- <div class="sub-header">
- <p class="game-intro">Join the tiles and get to <strong>2048!</strong> Use arrow keys or swipe to move.</p>
- <button class="new-game-btn" id="restart-button">New Game</button>
- </div>
- <div class="game-container" id="game-container">
- <div class="game-message" id="game-message">
- <p></p>
- <div class="lower">
- <button class="new-game-btn" id="retry-button">Try again</button>
- </div>
- </div>
- </div>
- <div class="footer">
- <p>Made with ❤️ • Swipe or use arrow keys to play</p>
- </div>
- </div>
- <script>
- document.addEventListener('DOMContentLoaded', () => {
- const GRID_SIZE = 4;
- const CELL_SIZE = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell-size'));
- const CELL_GAP = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cell-gap'));
- let grid = [];
- let score = 0;
- let bestScore = localStorage.getItem('bestScore') || 0;
- let gameOver = false;
- let gameWon = false;
- let touchStartX = 0;
- let touchStartY = 0;
- const gameContainer = document.getElementById('game-container');
- const scoreElement = document.getElementById('score');
- const bestScoreElement = document.getElementById('best-score');
- const messageContainer = document.getElementById('game-message');
- const restartButton = document.getElementById('restart-button');
- const retryButton = document.getElementById('retry-button');
- bestScoreElement.textContent = bestScore;
- // Initialize game
- function initGame() {
- createCellElements();
- resetGame();
- setupEventListeners();
- }
- // Create cell elements
- function createCellElements() {
- gameContainer.innerHTML = '';
- messageContainer.classList.remove('game-won', 'game-over');
- messageContainer.querySelector('p').textContent = '';
- gameContainer.appendChild(messageContainer);
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- const cell = document.createElement('div');
- cell.classList.add('cell');
- cell.style.top = `${CELL_GAP + i * (CELL_SIZE + CELL_GAP)}px`;
- cell.style.left = `${CELL_GAP + j * (CELL_SIZE + CELL_GAP)}px`;
- gameContainer.appendChild(cell);
- }
- }
- }
- // Reset game state
- function resetGame() {
- // Clear the grid
- grid = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(null));
- score = 0;
- gameOver = false;
- gameWon = false;
- // Update score display
- scoreElement.textContent = score;
- // Remove existing tiles
- const tiles = document.querySelectorAll('.tile');
- tiles.forEach(tile => tile.remove());
- // Reset game message
- messageContainer.classList.remove('game-won', 'game-over');
- // Add initial tiles
- addRandomTile();
- addRandomTile();
- }
- // Setup event listeners
- function setupEventListeners() {
- document.addEventListener('keydown', handleKeyPress);
- restartButton.addEventListener('click', resetGame);
- retryButton.addEventListener('click', resetGame);
- // Touch events for mobile
- gameContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
- gameContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
- gameContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
- }
- // Handle key presses
- function handleKeyPress(e) {
- if (gameOver || gameWon) return;
- let moved = false;
- switch(e.key) {
- case 'ArrowUp':
- moved = moveUp();
- break;
- case 'ArrowDown':
- moved = moveDown();
- break;
- case 'ArrowLeft':
- moved = moveLeft();
- break;
- case 'ArrowRight':
- moved = moveRight();
- break;
- default:
- return;
- }
- if (moved) {
- setTimeout(() => {
- addRandomTile();
- checkGameStatus();
- }, 150);
- }
- }
- // Touch event handlers
- function handleTouchStart(e) {
- touchStartX = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- }
- function handleTouchMove(e) {
- if (gameOver || gameWon) return;
- e.preventDefault();
- }
- function handleTouchEnd(e) {
- if (gameOver || gameWon) return;
- const touchEndX = e.changedTouches[0].clientX;
- const touchEndY = e.changedTouches[0].clientY;
- const deltaX = touchEndX - touchStartX;
- const deltaY = touchEndY - touchStartY;
- let moved = false;
- // Determine swipe direction based on the larger delta
- if (Math.abs(deltaX) > Math.abs(deltaY)) {
- if (deltaX > 20) {
- moved = moveRight();
- } else if (deltaX < -20) {
- moved = moveLeft();
- }
- } else {
- if (deltaY > 20) {
- moved = moveDown();
- } else if (deltaY < -20) {
- moved = moveUp();
- }
- }
- if (moved) {
- setTimeout(() => {
- addRandomTile();
- checkGameStatus();
- }, 150);
- }
- }
- // Movement functions
- function moveUp() {
- return moveTiles((i, j) => ({row: i, col: j}),
- (i) => i > 0,
- (i) => i - 1,
- () => -1);
- }
- function moveDown() {
- return moveTiles((i, j) => ({row: GRID_SIZE - 1 - i, col: j}),
- (i) => i < GRID_SIZE - 1,
- (i) => i + 1,
- () => 1);
- }
- function moveLeft() {
- return moveTiles((i, j) => ({row: j, col: i}),
- (i) => i > 0,
- (i) => i - 1,
- () => -1);
- }
- function moveRight() {
- return moveTiles((i, j) => ({row: j, col: GRID_SIZE - 1 - i}),
- (i) => i < GRID_SIZE - 1,
- (i) => i + 1,
- () => 1);
- }
- // Generic move tiles function
- function moveTiles(positionTransform, canMove, nextPos, direction) {
- let moved = false;
- const mergedPositions = new Set();
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- const { row, col } = positionTransform(i, j);
- if (grid[row][col] === null) continue;
- let currentRow = row;
- let currentCol = col;
- let dir = direction();
- // Move as far as possible in the direction
- while (canMove(dir === 1 ? currentRow : currentCol)) {
- const nextRow = dir === 0 ? currentRow : (dir === 1 ? nextPos(currentRow) : currentRow);
- const nextCol = dir === 0 ? (dir === -1 ? nextPos(currentCol) : currentCol) : nextCol = currentCol;
- // If next cell is empty, move there
- if (grid[nextRow][nextCol] === null) {
- grid[nextRow][nextCol] = grid[currentRow][currentCol];
- grid[currentRow][currentCol] = null;
- currentRow = nextRow;
- currentCol = nextCol;
- moved = true;
- }
- // If next cell has same value and hasn't been merged yet, merge them
- else if (grid[nextRow][nextCol].value === grid[currentRow][currentCol].value &&
- !mergedPositions.has(`${nextRow},${nextCol}`)) {
- const newValue = grid[currentRow][currentCol].value * 2;
- grid[nextRow][nextCol].value = newValue;
- grid[currentRow][currentCol] = null;
- // Mark as merged and update score
- mergedPositions.add(`${nextRow},${nextCol}`);
- score += newValue;
- scoreElement.textContent = score;
- // Update best score if needed
- if (score > bestScore) {
- bestScore = score;
- bestScoreElement.textContent = bestScore;
- localStorage.setItem('bestScore', bestScore);
- }
- moved = true;
- break;
- } else {
- // Can't move further
- break;
- }
- }
- }
- }
- if (moved) {
- updateTileDisplay();
- }
- return moved;
- }
- // Add a random tile to the grid
- function addRandomTile() {
- const emptyCells = [];
- // Find all empty cells
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- if (grid[i][j] === null) {
- emptyCells.push({row: i, col: j});
- }
- }
- }
- // If there are empty cells, add a new tile
- if (emptyCells.length > 0) {
- const {row, col} = emptyCells[Math.floor(Math.random() * emptyCells.length)];
- const value = Math.random() < 0.9 ? 2 : 4;
- grid[row][col] = { value, merged: false };
- // Create and add the tile element
- const tile = document.createElement('div');
- tile.classList.add('tile');
- tile.dataset.value = value;
- tile.textContent = value;
- tile.style.top = `${CELL_GAP + row * (CELL_SIZE + CELL_GAP)}px`;
- tile.style.left = `${CELL_GAP + col * (CELL_SIZE + CELL_GAP)}px`;
- gameContainer.appendChild(tile);
- }
- }
- // Update the display of all tiles
- function updateTileDisplay() {
- // Remove all existing tiles
- const existingTiles = document.querySelectorAll('.tile');
- existingTiles.forEach(tile => tile.remove());
- // Create new tiles based on grid state
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- if (grid[i][j] !== null) {
- const value = grid[i][j].value;
- const merged = grid[i][j].merged;
- const tile = document.createElement('div');
- tile.classList.add('tile');
- if (merged) tile.classList.add('merged');
- tile.dataset.value = value;
- tile.textContent = value;
- tile.style.top = `${CELL_GAP + i * (CELL_SIZE + CELL_GAP)}px`;
- tile.style.left = `${CELL_GAP + j * (CELL_SIZE + CELL_GAP)}px`;
- gameContainer.appendChild(tile);
- // Reset merged flag
- grid[i][j].merged = false;
- }
- }
- }
- }
- // Check if the game is over or won
- function checkGameStatus() {
- // Check for 2048 tile (win condition)
- if (!gameWon) {
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- if (grid[i][j] !== null && grid[i][j].value === 2048) {
- gameWon = true;
- messageContainer.classList.add('game-won');
- messageContainer.querySelector('p').textContent = 'You Win!';
- return;
- }
- }
- }
- }
- // Check if any moves are possible
- // First, check if there are any empty cells
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- if (grid[i][j] === null) return; // There's an empty cell, game not over
- }
- }
- // Check if any adjacent cells have the same value
- for (let i = 0; i < GRID_SIZE; i++) {
- for (let j = 0; j < GRID_SIZE; j++) {
- const value = grid[i][j].value;
- // Check right
- if (j < GRID_SIZE - 1 && grid[i][j + 1].value === value) return;
- // Check down
- if (i < GRID_SIZE - 1 && grid[i + 1][j].value === value) return;
- }
- }
- // If we reach here, game is over
- gameOver = true;
- messageContainer.classList.add('game-over');
- messageContainer.querySelector('p').textContent = 'Game Over!';
- }
- // Start the game
- initGame();
- });
- </script>
- </body>
- </html>
- ```
- This implementation includes:
- 1. A responsive design that works on both desktop and mobile devices
- 2. Touch/swipe controls for mobile devices
- 3. Animations for tile movements and merges
- 4. Score tracking with persistent high score (saved in localStorage)
- 5. Game win/lose detection
- 6. Clean, modern UI with color-coded tiles
- 7. Restart functionality
- To play the game:
- - On desktop: Use arrow keys (Up, Down, Left, Right)
- - On mobile: Swipe in the direction you want to move the tiles
- The game follows the classic 2048 rules - combine matching tiles to reach the 2048 tile!
- ## Gemini (Iteration 2)
- Okay, let's review Claude's code and enhance it. Claude has provided a solid foundation with good structure, responsiveness, and basic animations.
- **Critique of Claude's Version:**
- 1. **Animation & DOM Manipulation:** The biggest issue is how animations and tile updates are handled. The `updateTileDisplay` function removes *all* existing tile elements and redraws them based on the `grid` array *after* the logic for a move is complete. This prevents smooth animation of individual tiles moving across the board. The CSS `transition: transform 100ms` is likely not having the intended effect because the elements it would apply to are destroyed and recreated. The `merged` class and animation are added, but again, applied to newly created elements, not the ones involved in the merge visually.
- 2. **`moveTiles` Function:** While clever to use a generic function with transforms, it's quite complex and hard to debug. The logic for determining `nextRow`/`nextCol` based on `dir` seems overly complicated and potentially error-prone (especially the `nextCol = nextCol = currentCol` part which looks like a typo/bug).
- 3. **Performance:** Constantly destroying and recreating DOM elements, especially in a timed loop (`setTimeout`), is less performant than moving existing elements.
- 4. **Game Win Flow:** Once 2048 is reached, the game stops. A common enhancement is allowing the player to continue playing ("Keep Playing" button).
- 5. **Accessibility:** Basic accessibility features (like ARIA roles or live regions for score updates/game status) are missing.
- **Proposed Improvements:**
- 1. **Refactor DOM Manipulation for Animations:**
- * Represent tiles in the JS `grid` not just by value, but potentially by linking them to their DOM elements.
- * Instead of `updateTileDisplay`, modify the `move` functions to:
- * Calculate the target position for each tile.
- * Update the `top` and `left` CSS properties of the *existing* tile DOM elements. Use CSS transitions on `top` and `left` for smooth movement.
- * When tiles merge, update the value (`textContent`, `data-value`) of the target tile element, add the `merged` class for the pop animation, and *remove* the DOM element of the tile that merged into it.
- * When a new tile appears, create only that single new element and add an "appear" animation.
- 2. **Simplify Movement Logic:** Potentially rewrite the movement logic for each direction more explicitly (e.g., `processMoveUp`, `processMoveLeft`) rather than the complex generic `moveTiles`. This might involve helper functions for sliding and merging within a single row/column.
- 3. **"Keep Playing" Feature:** Modify the win condition check to allow the game to continue after the "You Win!" message is displayed, if the user chooses.
- 4. **Add Basic Accessibility:** Use `aria-live` regions for score updates and game status messages.
- 5. **Code Structure:** Minor improvements for clarity and separation.
- ---
- Here is the improved version incorporating these changes:
- ```html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Enhanced 2048 Refactored</title>
- <style>
- :root {
- --grid-size: 4;
- --cell-size: 100px;
- --cell-gap: 12px;
- --border-radius: 8px;
- --animation-speed: 100ms; /* For movement */
- --appear-speed: 200ms;
- --pop-speed: 200ms;
- }
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: 'Arial', sans-serif;
- }
- body {
- background-color: #faf8ef;
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 100vh; /* Use min-height for flexibility */
- overflow-y: auto; /* Allow scrolling if content overflows */
- touch-action: none; /* Prevent browser default touch actions */
- padding: 10px; /* Add padding for small screens */
- }
- .container {
- width: min(95vw, 500px); /* Adjusted max-width */
- padding: 15px;
- border-radius: 10px;
- background-color: #bbada0;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap; /* Allow wrapping on small screens */
- gap: 10px;
- }
- .title {
- color: #776e65;
- font-size: clamp(2rem, 8vw, 2.5rem); /* Responsive font size */
- font-weight: bold;
- }
- .scores {
- display: flex;
- gap: 10px;
- }
- .score-box {
- background-color: #8f7a66;
- color: white;
- padding: 8px 12px;
- border-radius: 5px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 70px;
- text-align: center;
- }
- .score-title {
- font-size: 0.7rem;
- text-transform: uppercase;
- margin-bottom: 2px;
- }
- .score-value {
- font-size: clamp(1.2rem, 5vw, 1.5rem);
- font-weight: bold;
- }
- .sub-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 10px;
- }
- .game-intro {
- color: #776e65;
- flex-basis: 60%; /* Allow text to take more space */
- font-size: 0.9rem;
- line-height: 1.3;
- }
- .new-game-btn {
- background-color: #8f7a66;
- color: white;
- border: none;
- border-radius: 5px;
- padding: 10px 15px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.2s ease;
- white-space: nowrap; /* Prevent button text wrapping */
- }
- .new-game-btn:hover, .new-game-btn:focus {
- background-color: #9f8b77;
- outline: none;
- }
- .game-container {
- position: relative;
- /* Calculate width/height based on CSS variables */
- width: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- height: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- background-color: #bbada0; /* Background for the grid area */
- border-radius: var(--border-radius);
- padding: var(--cell-gap);
- margin: 0 auto; /* Center the grid */
- overflow: hidden; /* Hide parts of tiles moving outside */
- }
- .grid-background {
- position: absolute;
- top: var(--cell-gap);
- left: var(--cell-gap);
- bottom: var(--cell-gap);
- right: var(--cell-gap);
- display: grid;
- grid-template-columns: repeat(var(--grid-size), var(--cell-size));
- grid-template-rows: repeat(var(--grid-size), var(--cell-size));
- gap: var(--cell-gap);
- }
- .cell {
- background-color: rgba(238, 228, 218, 0.35);
- border-radius: var(--border-radius);
- }
- .tile {
- position: absolute;
- display: flex;
- justify-content: center;
- align-items: center;
- width: var(--cell-size);
- height: var(--cell-size);
- border-radius: var(--border-radius);
- font-size: clamp(1.5rem, 7vw, 2rem); /* Responsive font size */
- font-weight: bold;
- background-color: #eee4da; /* Default tile color */
- color: #776e65; /* Default text color */
- /* Animate position changes */
- transition: top var(--animation-speed) ease-in-out,
- left var(--animation-speed) ease-in-out;
- z-index: 10;
- /* Prevent text selection */
- user-select: none;
- }
- /* Animation for new tiles */
- .tile.new-tile {
- animation: appear var(--appear-speed) ease-in-out;
- }
- @keyframes appear {
- 0% {
- opacity: 0;
- transform: scale(0);
- }
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- }
- /* Animation for merged tiles */
- .tile.merged-tile {
- animation: pop var(--pop-speed) ease-in-out;
- z-index: 20; /* Ensure merged tile pops above others */
- }
- @keyframes pop {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.2);
- }
- 100% {
- transform: scale(1);
- }
- }
- /* Tile Colors - Using data attributes */
- .tile[data-value="2"] { background-color: #eee4da; color: #776e65; }
- .tile[data-value="4"] { background-color: #ede0c8; color: #776e65; }
- .tile[data-value="8"] { background-color: #f2b179; color: #f9f6f2; }
- .tile[data-value="16"] { background-color: #f59563; color: #f9f6f2; }
- .tile[data-value="32"] { background-color: #f67c5f; color: #f9f6f2; }
- .tile[data-value="64"] { background-color: #f65e3b; color: #f9f6f2; }
- .tile[data-value="128"] { background-color: #edcf72; color: #f9f6f2; font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="256"] { background-color: #edcc61; color: #f9f6f2; font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="512"] { background-color: #edc850; color: #f9f6f2; font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="1024"] { background-color: #edc53f; color: #f9f6f2; font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="2048"] { background-color: #edc22e; color: #f9f6f2; font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="4096"] { background-color: #3c3a32; color: #f9f6f2; font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="8192"] { background-color: #3c3a32; color: #f9f6f2; font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .game-message {
- /* Start hidden */
- display: none;
- position: absolute;
- top: 0; right: 0; bottom: 0; left: 0;
- background-color: rgba(238, 228, 218, 0.73);
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- border-radius: var(--border-radius);
- z-index: 100;
- opacity: 0; /* Start transparent for fade-in */
- transition: opacity 0.3s ease-in-out;
- animation: fadein 0.3s ease-in-out forwards;
- }
- .game-message.active {
- display: flex;
- opacity: 1; /* Fade in */
- }
- @keyframes fadein {
- from { opacity: 0; }
- to { opacity: 1; }
- }
- .game-message p {
- font-size: clamp(1.8rem, 7vw, 2rem);
- font-weight: bold;
- color: #776e65;
- margin-bottom: 20px;
- }
- .game-message .lower {
- display: flex;
- gap: 10px;
- margin-top: 10px;
- }
- /* Responsive adjustments */
- @media screen and (max-width: 520px) {
- :root {
- --cell-size: 65px;
- --cell-gap: 8px;
- }
- .game-intro { flex-basis: 100%; text-align: center; margin-bottom: 10px; }
- .new-game-btn { padding: 8px 12px; }
- }
- @media screen and (max-width: 380px) {
- :root {
- --cell-size: 55px;
- --cell-gap: 6px;
- }
- .container { padding: 10px; }
- .header { justify-content: center; } /* Center title and scores */
- .title { text-align: center; width: 100%; margin-bottom: 5px;}
- .sub-header { justify-content: center; }
- .score-box { min-width: 60px; padding: 6px 10px; }
- }
- .footer {
- margin-top: 20px;
- text-align: center;
- color: #776e65;
- font-size: 0.8rem;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <header class="header">
- <h1 class="title">2048</h1>
- <div class="scores">
- <div class="score-box">
- <div class="score-title">Score</div>
- <div class="score-value" id="score" aria-live="polite">0</div>
- </div>
- <div class="score-box">
- <div class="score-title">Best</div>
- <div class="score-value" id="best-score">0</div>
- </div>
- </div>
- </header>
- <div class="sub-header">
- <p class="game-intro">Join tiles, get to <strong>2048!</strong> Use <strong>arrow keys</strong> or <strong>swipe</strong>.</p>
- <button class="new-game-btn" id="restart-button">New Game</button>
- </div>
- <div class="game-container" id="game-container">
- <div class="grid-background" id="grid-background">
- <!-- Background cells are generated here by JS -->
- </div>
- <div class="tile-container" id="tile-container">
- <!-- Tiles are added here by JS -->
- </div>
- <div class="game-message" id="game-message" aria-live="assertive">
- <p id="message-text"></p>
- <div class="lower">
- <button class="new-game-btn" id="retry-button">Try again</button>
- <button class="new-game-btn" id="keep-playing-button" style="display: none;">Keep Playing</button>
- </div>
- </div>
- </div>
- <footer class="footer">
- <p>Refactored Version • Swipe or use arrow keys</p>
- </footer>
- </div>
- <script>
- document.addEventListener('DOMContentLoaded', () => {
- const GRID_SIZE = 4;
- const WINNING_VALUE = 2048;
- const MOVE_ANIMATION_DURATION = 100; // ms, matches CSS --animation-speed
- const APPEAR_ANIMATION_DURATION = 200; // ms, matches CSS --appear-speed
- // --- DOM Elements ---
- const gameContainer = document.getElementById('game-container');
- const gridBackground = document.getElementById('grid-background');
- const tileContainer = document.getElementById('tile-container');
- const scoreElement = document.getElementById('score');
- const bestScoreElement = document.getElementById('best-score');
- const messageContainer = document.getElementById('game-message');
- const messageText = document.getElementById('message-text');
- const restartButton = document.getElementById('restart-button');
- const retryButton = document.getElementById('retry-button');
- const keepPlayingButton = document.getElementById('keep-playing-button');
- // --- Game State ---
- let grid; // Will be a 2D array representing cells (Tile objects or null)
- let score = 0;
- let bestScore = localStorage.getItem('bestScore2048') || 0;
- let isGameOver = false;
- let hasWon = false; // Tracks if 2048 has been reached
- let keepPlayingMode = false; // Tracks if user chose to continue after winning
- let isMoving = false; // Prevent input during animations
- // --- Touch Input State ---
- let touchStartX = 0;
- let touchStartY = 0;
- let touchEndX = 0;
- let touchEndY = 0;
- // --- Tile Class ---
- class Tile {
- constructor(value, row, col) {
- this.value = value;
- this.row = row;
- this.col = col;
- this.element = this.createElement();
- this.updatePosition(false); // Initial position without animation
- this.mergedFrom = null; // Track merge source for animation coordination
- tileContainer.appendChild(this.element);
- }
- createElement() {
- const tileElement = document.createElement('div');
- tileElement.classList.add('tile');
- this.updateValue(this.value); // Set initial text and data-value
- // Add new tile animation class, remove after animation
- tileElement.classList.add('new-tile');
- setTimeout(() => {
- tileElement.classList.remove('new-tile');
- }, APPEAR_ANIMATION_DURATION);
- return tileElement;
- }
- updateValue(newValue) {
- this.value = newValue;
- this.element.textContent = newValue;
- this.element.dataset.value = newValue; // For CSS styling
- }
- updatePosition(animate = true) {
- const { top, left } = this.calculatePixelPosition();
- this.element.style.transition = animate ? `top ${MOVE_ANIMATION_DURATION}ms ease-in-out, left ${MOVE_ANIMATION_DURATION}ms ease-in-out` : 'none';
- this.element.style.top = `${top}px`;
- this.element.style.left = `${left}px`;
- }
- calculatePixelPosition() {
- // Need to get these values dynamically in case they change (e.g., responsive)
- const cellSize = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-size'));
- const cellGap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-gap'));
- const top = this.row * (cellSize + cellGap) + cellGap;
- const left = this.col * (cellSize + cellGap) + cellGap;
- return { top, left };
- }
- remove(merged = false) {
- if (merged) {
- // If merged, let the pop animation finish before removing
- this.element.classList.add('merged-tile');
- this.element.addEventListener('animationend', () => {
- if (this.element.parentNode) {
- tileContainer.removeChild(this.element);
- }
- }, { once: true });
- } else {
- if (this.element.parentNode) {
- tileContainer.removeChild(this.element);
- }
- }
- }
- playMergeAnimation() {
- this.element.classList.add('merged-tile');
- // Remove class after animation to allow re-triggering
- this.element.addEventListener('animationend', () => {
- this.element.classList.remove('merged-tile');
- }, { once: true });
- }
- }
- // --- Grid Initialization ---
- function createGridCells() {
- gridBackground.innerHTML = ''; // Clear previous cells
- for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
- const cell = document.createElement('div');
- cell.classList.add('cell');
- gridBackground.appendChild(cell);
- }
- }
- function initializeGrid() {
- grid = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- }
- // --- Game Setup ---
- function setupGame() {
- isMoving = true; // Prevent input during setup
- initializeGrid();
- tileContainer.innerHTML = ''; // Clear existing tiles
- updateScore(0);
- bestScoreElement.textContent = bestScore;
- isGameOver = false;
- hasWon = false;
- keepPlayingMode = false;
- hideMessage();
- addRandomTile();
- addRandomTile();
- isMoving = false; // Allow input
- }
- // --- Score Management ---
- function updateScore(newScore) {
- score = newScore;
- scoreElement.textContent = score;
- if (score > bestScore) {
- bestScore = score;
- bestScoreElement.textContent = bestScore;
- localStorage.setItem('bestScore2048', bestScore);
- }
- }
- // --- Tile Management ---
- function getEmptyCells() {
- const emptyCells = [];
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c] === null) {
- emptyCells.push({ r, c });
- }
- }
- }
- return emptyCells;
- }
- function addRandomTile() {
- const emptyCells = getEmptyCells();
- if (emptyCells.length === 0) return; // No space left
- const { r, c } = emptyCells[Math.floor(Math.random() * emptyCells.length)];
- const value = Math.random() < 0.9 ? 2 : 4;
- const newTile = new Tile(value, r, c);
- grid[r][c] = newTile;
- }
- // --- Movement Logic ---
- function move(direction) {
- if (isMoving || isGameOver) return;
- isMoving = true;
- let moved = false;
- let currentScore = score; // Track score changes from this move
- // Prepare for move: reset merge tracking
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c]) grid[r][c].mergedFrom = null;
- }
- }
- // Define traversal order based on direction
- const traversals = getTraversals(direction);
- traversals.rows.forEach(r => {
- traversals.cols.forEach(c => {
- const currentTile = grid[r][c];
- if (currentTile) {
- const { furthestRow, furthestCol, nextTile } = findFurthestPosition(r, c, direction);
- if (nextTile && nextTile.value === currentTile.value && !nextTile.mergedFrom) {
- // --- Merge ---
- const mergedValue = currentTile.value * 2;
- nextTile.updateValue(mergedValue);
- nextTile.mergedFrom = currentTile; // Mark as merged
- nextTile.playMergeAnimation(); // Play pop animation on target
- grid[r][c] = null; // Remove current tile from grid
- currentTile.remove(true); // Remove current tile element (with animation flag)
- currentScore += mergedValue;
- moved = true;
- // Check for win condition immediately after merge
- if (!hasWon && mergedValue === WINNING_VALUE) {
- hasWon = true;
- }
- } else if (furthestRow !== r || furthestCol !== c) {
- // --- Move ---
- grid[furthestRow][furthestCol] = currentTile;
- grid[r][c] = null;
- currentTile.row = furthestRow;
- currentTile.col = furthestCol;
- currentTile.updatePosition(); // Animate move
- moved = true;
- }
- }
- });
- });
- if (moved) {
- updateScore(currentScore); // Update score display after all merges calculation
- // Use setTimeout to allow move/merge animations to mostly complete
- setTimeout(() => {
- addRandomTile();
- if (!canMove()) {
- checkGameOver();
- } else if (hasWon && !keepPlayingMode) {
- showWinMessage();
- }
- isMoving = false; // Re-enable input
- }, MOVE_ANIMATION_DURATION + 50); // Wait slightly longer than move animation
- } else {
- isMoving = false; // No move occurred, re-enable input immediately
- }
- }
- function getTraversals(direction) {
- const rows = Array.from({ length: GRID_SIZE }, (_, i) => i);
- const cols = Array.from({ length: GRID_SIZE }, (_, i) => i);
- if (direction === 'right') cols.reverse();
- if (direction === 'down') rows.reverse();
- return { rows, cols };
- }
- function findFurthestPosition(r, c, direction) {
- let currentRow = r;
- let currentCol = c;
- let nextRow, nextCol;
- do {
- currentRow = nextRow !== undefined ? nextRow : r;
- currentCol = nextCol !== undefined ? nextCol : c;
- const { nr, nc } = getNextPosition(currentRow, currentCol, direction);
- nextRow = nr;
- nextCol = nc;
- } while (isWithinBounds(nextRow, nextCol) && grid[nextRow][nextCol] === null);
- // The loop went one step too far or hit a tile
- const furthestRow = currentRow;
- const furthestCol = currentCol;
- const nextTile = isWithinBounds(nextRow, nextCol) ? grid[nextRow][nextCol] : null;
- return { furthestRow, furthestCol, nextTile };
- }
- function getNextPosition(r, c, direction) {
- switch (direction) {
- case 'up': return { nr: r - 1, nc: c };
- case 'down': return { nr: r + 1, nc: c };
- case 'left': return { nr: r, nc: c - 1 };
- case 'right': return { nr: r, nc: c + 1 };
- default: return { nr: r, nc: c };
- }
- }
- function isWithinBounds(r, c) {
- return r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE;
- }
- // --- Game Status Checks ---
- function canMove() {
- if (getEmptyCells().length > 0) return true; // Can always move if empty cells exist
- // Check for possible merges horizontally and vertically
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- const tile = grid[r][c];
- if (!tile) continue; // Should not happen if no empty cells, but safe check
- // Check right
- if (c < GRID_SIZE - 1 && grid[r][c + 1] && grid[r][c + 1].value === tile.value) {
- return true;
- }
- // Check down
- if (r < GRID_SIZE - 1 && grid[r + 1][c] && grid[r + 1][c].value === tile.value) {
- return true;
- }
- }
- }
- return false; // No empty cells and no possible merges
- }
- function checkGameOver() {
- if (!isGameOver && !canMove()) {
- isGameOver = true;
- showGameOverMessage();
- }
- }
- // --- Message Display ---
- function showMessage(text, type) {
- messageText.textContent = text;
- messageContainer.className = 'game-message active'; // Reset classes and add active
- keepPlayingButton.style.display = 'none'; // Hide by default
- if (type === 'win') {
- messageContainer.classList.add('game-won');
- if (!keepPlayingMode) {
- keepPlayingButton.style.display = 'inline-block'; // Show keep playing
- }
- } else if (type === 'over') {
- messageContainer.classList.add('game-over');
- }
- }
- function showWinMessage() {
- showMessage('You Win!', 'win');
- }
- function showGameOverMessage() {
- showMessage('Game Over!', 'over');
- }
- function hideMessage() {
- messageContainer.classList.remove('active');
- // Optional: Delay removal of other classes if needed for fade out
- // setTimeout(() => {
- // messageContainer.classList.remove('game-won', 'game-over');
- // }, 300); // Match fade out duration
- }
- // --- Event Listeners ---
- function handleKeyDown(e) {
- if (isMoving || isGameOver) return;
- switch (e.key) {
- case 'ArrowUp':
- case 'w': // Add WASD support
- e.preventDefault(); // Prevent page scrolling
- move('up');
- break;
- case 'ArrowDown':
- case 's':
- e.preventDefault();
- move('down');
- break;
- case 'ArrowLeft':
- case 'a':
- e.preventDefault();
- move('left');
- break;
- case 'ArrowRight':
- case 'd':
- e.preventDefault();
- move('right');
- break;
- }
- }
- function handleTouchStart(e) {
- if (isMoving || isGameOver || e.touches.length > 1) return; // Ignore multi-touch
- touchStartX = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- touchEndX = touchStartX; // Reset end points
- touchEndY = touchStartY;
- // e.preventDefault(); // Prevent scroll bounce effect on start, maybe not needed with touch-action: none
- }
- function handleTouchMove(e) {
- if (isMoving || isGameOver || e.touches.length > 1) return;
- // Update end points for continuous tracking if needed, but mainly for the final calculation
- touchEndX = e.touches[0].clientX;
- touchEndY = e.touches[0].clientY;
- e.preventDefault(); // Prevent scrolling *during* swipe detection
- }
- function handleTouchEnd(e) {
- if (isMoving || isGameOver || e.changedTouches.length > 1) return;
- const deltaX = touchEndX - touchStartX;
- const deltaY = touchEndY - touchStartY;
- const absDeltaX = Math.abs(deltaX);
- const absDeltaY = Math.abs(deltaY);
- const swipeThreshold = 30; // Minimum distance for a swipe
- if (Math.max(absDeltaX, absDeltaY) < swipeThreshold) return; // Not a swipe
- let direction;
- if (absDeltaX > absDeltaY) {
- // Horizontal swipe
- direction = deltaX > 0 ? 'right' : 'left';
- } else {
- // Vertical swipe
- direction = deltaY > 0 ? 'down' : 'up';
- }
- move(direction);
- }
- // --- Button Actions ---
- restartButton.addEventListener('click', setupGame);
- retryButton.addEventListener('click', setupGame);
- keepPlayingButton.addEventListener('click', () => {
- keepPlayingMode = true;
- hideMessage();
- isMoving = false; // Ensure input is re-enabled
- });
- // --- Initialize ---
- createGridCells(); // Create the static background cells once
- setupGame(); // Setup initial game state
- // Add keyboard listeners
- document.addEventListener('keydown', handleKeyDown);
- // Add touch listeners (use passive: false for move to allow preventDefault)
- gameContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
- gameContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
- gameContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
- // Listen for resize to recalculate tile positions if necessary (though CSS handles most)
- // window.addEventListener('resize', () => { /* Could force redraw if needed */ });
- });
- </script>
- </body>
- </html>
- ```
- **Explanation of Changes:**
- 1. **HTML Structure:**
- * Added a `div.grid-background` and `div.tile-container` inside `game-container`. The background holds the static empty cell visuals, and the `tile-container` holds the actual moving tile elements. This separation makes managing absolutely positioned tiles easier.
- * Added `aria-live="polite"` to the score display for screen readers.
- * Added `aria-live="assertive"` to the game message overlay.
- * Added a "Keep Playing" button (`#keep-playing-button`), initially hidden.
- * Added `clamp()` for responsive font sizes in CSS.
- * Added `min-height: 100vh` and `overflow-y: auto` to `body` for better handling on different screen sizes.
- 2. **CSS Enhancements:**
- * Defined `--animation-speed`, `--appear-speed`, `--pop-speed` variables.
- * Tiles now transition `top` and `left` properties for movement.
- * Renamed `appear` animation class to `new-tile` for clarity.
- * Renamed `pop` animation class to `merged-tile`.
- * Added `z-index` to ensure merged tiles pop visually above others.
- * Improved responsiveness with `clamp()` and minor layout adjustments in media queries.
- * Added styles for tiles > 2048 (e.g., 4096).
- * Game message now fades in using `opacity` and `transition`/`animation`.
- 3. **JavaScript Refactoring:**
- * **`Tile` Class:** Introduced a `Tile` class to manage each tile's state (value, row, col) and its corresponding DOM element. This class handles creating the element, updating its value/position, playing animations, and removing itself.
- * **DOM Manipulation:**
- * Tiles are now represented by `Tile` objects stored in the `grid` array.
- * `addRandomTile` creates a `new Tile()` instance, which appends its element to the `tile-container`.
- * Movement logic (`move` function) now updates the `row`, `col` properties of `Tile` objects and calls their `updatePosition()` method, which changes the `top`/`left` style, triggering the CSS transition.
- * Merging involves updating the target `Tile`'s value, calling its `playMergeAnimation()`, and calling the source `Tile`'s `remove(true)` method (which handles removal after animation).
- * **Movement Logic (`move` function):**
- * Rewrote the core movement logic. It's now more explicit:
- * Determines traversal order based on direction (`getTraversals`).
- * Iterates through cells in the correct order.
- * For each tile, finds its furthest destination or potential merge target (`findFurthestPosition`).
- * Performs either a move (updating grid and tile position) or a merge (updating target tile, removing source tile, updating score).
- * Uses helper functions (`getTraversals`, `findFurthestPosition`, `getNextPosition`, `isWithinBounds`) for clarity.
- * Uses a `mergedFrom` property on the `Tile` object to prevent double merges in a single move.
- * **Animation Coordination:** Uses `setTimeout` after a move completes to allow CSS transitions/animations to largely finish before adding the next random tile and checking game state. The durations match the CSS variables. The `Tile` class manages adding/removing animation classes (`new-tile`, `merged-tile`).
- * **Game State Management:** Introduced `isMoving` flag to prevent concurrent moves or input during animations. Added `hasWon` and `keepPlayingMode` flags.
- * **"Keep Playing":** The win message now shows a "Keep Playing" button. Clicking it sets `keepPlayingMode = true` and hides the message, allowing the game to continue. The win check (`hasWon`) ensures the message only appears once unless the game is reset.
- * **Game Over Check:** `checkGameOver` is called only if a move occurred and `canMove()` returns false.
- * **Touch Handling:** Improved swipe detection logic slightly for clarity and added a threshold. Uses `passive: false` on `touchmove` to correctly allow `preventDefault()` and stop scrolling during swipe detection.
- * **Event Handling:** Added WASD key support alongside arrow keys. `preventDefault` is used for arrow keys to stop page scrolling.
- * **Initialization:** `createGridCells` now runs once to set up the background. `setupGame` handles resetting the state and adding initial tiles.
- This refactored version provides a much smoother user experience due to proper animation handling, is arguably easier to maintain with the `Tile` class, and includes the common "Keep Playing" feature.
- ## Claude (Iteration 2)
- # Enhanced 2048 Game - Further Improvements
- I've reviewed Gemini's refactored version of the 2048 game and will build upon it. Gemini made excellent improvements to the animation system, code organization, and added the "Keep Playing" feature. Let me enhance it further with additional features and optimizations.
- ## Analysis of Gemini's Implementation
- Gemini's refactoring addressed several key issues:
- - Created a proper Tile class with DOM element management
- - Implemented smooth animations for tile movement and merges
- - Added accessibility features with aria-live regions
- - Improved responsiveness and touch handling
- - Added the "Keep Playing" feature after reaching 2048
- ## My Additional Enhancements
- I'll further improve the game with:
- 1. **Game history and undo functionality**
- 2. **Keyboard and touch accessibility improvements**
- 3. **Game statistics tracking**
- 4. **Dark mode support**
- 5. **Save game state persistence**
- 6. **Performance optimizations**
- 7. **Better animation control**
- 8. **Visual and UX enhancements**
- ## The Enhanced Implementation
- ```html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>2048 - Advanced Edition</title>
- <style>
- :root {
- --grid-size: 4;
- --cell-size: 100px;
- --cell-gap: 12px;
- --border-radius: 8px;
- --animation-speed: 150ms; /* Slightly increased for smoother feel */
- --appear-speed: 200ms;
- --pop-speed: 200ms;
- /* Color scheme - light mode (default) */
- --bg-color: #faf8ef;
- --container-bg: #bbada0;
- --cell-bg: rgba(238, 228, 218, 0.35);
- --title-color: #776e65;
- --text-color: #776e65;
- --button-bg: #8f7a66;
- --button-hover-bg: #9f8b77;
- --button-text: #f9f6f2;
- --overlay-bg: rgba(238, 228, 218, 0.73);
- --tile-2-bg: #eee4da;
- --tile-2-text: #776e65;
- --tile-4-bg: #ede0c8;
- --tile-4-text: #776e65;
- --tile-8-bg: #f2b179;
- --tile-8-text: #f9f6f2;
- --tile-16-bg: #f59563;
- --tile-16-text: #f9f6f2;
- --tile-32-bg: #f67c5f;
- --tile-32-text: #f9f6f2;
- --tile-64-bg: #f65e3b;
- --tile-64-text: #f9f6f2;
- --tile-128-bg: #edcf72;
- --tile-128-text: #f9f6f2;
- --tile-256-bg: #edcc61;
- --tile-256-text: #f9f6f2;
- --tile-512-bg: #edc850;
- --tile-512-text: #f9f6f2;
- --tile-1024-bg: #edc53f;
- --tile-1024-text: #f9f6f2;
- --tile-2048-bg: #edc22e;
- --tile-2048-text: #f9f6f2;
- --tile-super-bg: #3c3a32;
- --tile-super-text: #f9f6f2;
- }
- /* Dark mode color scheme */
- [data-theme="dark"] {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- /* Other tile colors remain the same for recognition */
- }
- @media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- }
- }
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: 'Arial', sans-serif;
- }
- body {
- background-color: var(--bg-color);
- color: var(--text-color);
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- overflow-y: auto;
- touch-action: none;
- padding: 10px;
- transition: background-color 0.3s ease, color 0.3s ease;
- }
- .container {
- width: min(95vw, 500px);
- padding: 15px;
- border-radius: 10px;
- background-color: var(--container-bg);
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
- transition: background-color 0.3s ease;
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 10px;
- }
- .title-container {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .title {
- color: var(--title-color);
- font-size: clamp(2rem, 8vw, 2.5rem);
- font-weight: bold;
- transition: color 0.3s ease;
- }
- .theme-toggle {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 1.5rem;
- color: var(--text-color);
- transition: transform 0.3s ease;
- padding: 5px;
- margin-left: 10px;
- }
- .theme-toggle:hover {
- transform: rotate(30deg);
- }
- .scores {
- display: flex;
- gap: 10px;
- }
- .score-box {
- background-color: var(--button-bg);
- color: var(--button-text);
- padding: 8px 12px;
- border-radius: 5px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 70px;
- text-align: center;
- transition: background-color 0.3s ease;
- }
- .score-title {
- font-size: 0.7rem;
- text-transform: uppercase;
- margin-bottom: 2px;
- }
- .score-value {
- font-size: clamp(1.2rem, 5vw, 1.5rem);
- font-weight: bold;
- }
- .sub-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 10px;
- }
- .game-intro {
- color: var(--text-color);
- flex-basis: 60%;
- font-size: 0.9rem;
- line-height: 1.3;
- transition: color 0.3s ease;
- }
- .button-container {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- }
- .btn {
- background-color: var(--button-bg);
- color: var(--button-text);
- border: none;
- border-radius: 5px;
- padding: 10px 15px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.2s ease, transform 0.1s ease;
- white-space: nowrap;
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 40px;
- }
- .btn:hover, .btn:focus {
- background-color: var(--button-hover-bg);
- outline: none;
- }
- .btn:active {
- transform: scale(0.95);
- }
- .btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .btn-icon {
- font-size: 1.2rem;
- line-height: 1;
- }
- .game-container {
- position: relative;
- width: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- height: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- background-color: var(--container-bg);
- border-radius: var(--border-radius);
- padding: var(--cell-gap);
- margin: 0 auto;
- overflow: hidden;
- transition: background-color 0.3s ease;
- }
- .grid-background {
- position: absolute;
- top: var(--cell-gap);
- left: var(--cell-gap);
- bottom: var(--cell-gap);
- right: var(--cell-gap);
- display: grid;
- grid-template-columns: repeat(var(--grid-size), var(--cell-size));
- grid-template-rows: repeat(var(--grid-size), var(--cell-size));
- gap: var(--cell-gap);
- }
- .cell {
- background-color: var(--cell-bg);
- border-radius: var(--border-radius);
- transition: background-color 0.3s ease;
- }
- .tile {
- position: absolute;
- display: flex;
- justify-content: center;
- align-items: center;
- width: var(--cell-size);
- height: var(--cell-size);
- border-radius: var(--border-radius);
- font-size: clamp(1.5rem, 7vw, 2rem);
- font-weight: bold;
- background-color: var(--tile-2-bg);
- color: var(--tile-2-text);
- transition: top var(--animation-speed) ease-in-out,
- left var(--animation-speed) ease-in-out,
- background-color 0.3s ease,
- color 0.3s ease;
- z-index: 10;
- user-select: none;
- will-change: transform, opacity; /* Performance hint */
- }
- .tile.new-tile {
- animation: appear var(--appear-speed) ease-in-out;
- }
- @keyframes appear {
- 0% {
- opacity: 0;
- transform: scale(0);
- }
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- }
- .tile.merged-tile {
- animation: pop var(--pop-speed) ease-in-out;
- z-index: 20;
- }
- @keyframes pop {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.2);
- }
- 100% {
- transform: scale(1);
- }
- }
- /* Tile Colors - Using CSS variables for theme support */
- .tile[data-value="2"] { background-color: var(--tile-2-bg); color: var(--tile-2-text); }
- .tile[data-value="4"] { background-color: var(--tile-4-bg); color: var(--tile-4-text); }
- .tile[data-value="8"] { background-color: var(--tile-8-bg); color: var(--tile-8-text); }
- .tile[data-value="16"] { background-color: var(--tile-16-bg); color: var(--tile-16-text); }
- .tile[data-value="32"] { background-color: var(--tile-32-bg); color: var(--tile-32-text); }
- .tile[data-value="64"] { background-color: var(--tile-64-bg); color: var(--tile-64-text); }
- .tile[data-value="128"] { background-color: var(--tile-128-bg); color: var(--tile-128-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="256"] { background-color: var(--tile-256-bg); color: var(--tile-256-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="512"] { background-color: var(--tile-512-bg); color: var(--tile-512-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="1024"] { background-color: var(--tile-1024-bg); color: var(--tile-1024-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="2048"] { background-color: var(--tile-2048-bg); color: var(--tile-2048-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="4096"],
- .tile[data-value="8192"],
- .tile[data-value="16384"],
- .tile[data-value="32768"],
- .tile[data-value="65536"] {
- background-color: var(--tile-super-bg);
- color: var(--tile-super-text);
- font-size: clamp(1rem, 4.5vw, 1.4rem);
- }
- .game-message {
- display: none;
- position: absolute;
- top: 0; right: 0; bottom: 0; left: 0;
- background-color: var(--overlay-bg);
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- border-radius: var(--border-radius);
- z-index: 100;
- opacity: 0;
- transition: opacity 0.3s ease-in-out, background-color 0.3s ease;
- animation: fadein 0.3s ease-in-out forwards;
- }
- .game-message.active {
- display: flex;
- opacity: 1;
- }
- @keyframes fadein {
- from { opacity: 0; }
- to { opacity: 1; }
- }
- .game-message p {
- font-size: clamp(1.8rem, 7vw, 2rem);
- font-weight: bold;
- color: var(--title-color);
- margin-bottom: 20px;
- transition: color 0.3s ease;
- }
- .game-message .lower {
- display: flex;
- gap: 10px;
- margin-top: 10px;
- }
- .score-addition {
- position: absolute;
- animation: score-pop 0.8s ease-in-out;
- font-weight: bold;
- color: var(--title-color);
- z-index: 100;
- pointer-events: none;
- font-size: 1.2rem;
- opacity: 0;
- }
- @keyframes score-pop {
- 0% {
- opacity: 1;
- transform: translate(-50%, 0);
- }
- 100% {
- opacity: 0;
- transform: translate(-50%, -20px);
- }
- }
- .stats-container {
- margin-top: 10px;
- background-color: var(--container-bg);
- border-radius: 8px;
- padding: 10px 15px;
- display: flex;
- flex-direction: column;
- gap: 5px;
- width: 100%;
- transition: background-color 0.3s ease;
- }
- .stats-title {
- font-weight: bold;
- color: var(--title-color);
- font-size: 1.1rem;
- margin-bottom: 5px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: color 0.3s ease;
- }
- .stats-row {
- display: flex;
- justify-content: space-between;
- font-size: 0.9rem;
- color: var(--text-color);
- transition: color 0.3s ease;
- }
- .stats-value {
- font-weight: bold;
- }
- .stats-toggle {
- font-size: 1rem;
- cursor: pointer;
- background: none;
- border: none;
- color: var(--text-color);
- transition: transform 0.3s ease, color 0.3s ease;
- }
- .stats-toggle:hover {
- transform: rotate(180deg);
- }
- .stats-content {
- overflow: hidden;
- max-height: 0;
- transition: max-height 0.3s ease-in-out;
- }
- .stats-content.expanded {
- max-height: 200px; /* Adjust based on content */
- }
- /* Responsive adjustments */
- @media screen and (max-width: 520px) {
- :root {
- --cell-size: 65px;
- --cell-gap: 8px;
- }
- .game-intro { flex-basis: 100%; text-align: center; margin-bottom: 10px; }
- .btn { padding: 8px 12px; }
- .button-container { justify-content: center; width: 100%; }
- }
- @media screen and (max-width: 380px) {
- :root {
- --cell-size: 55px;
- --cell-gap: 6px;
- }
- .container { padding: 10px; }
- .header { justify-content: center; }
- .title { text-align: center; width: 100%; margin-bottom: 5px;}
- .sub-header { justify-content: center; }
- .score-box { min-width: 60px; padding: 6px 10px; }
- .button-container { flex-wrap: wrap; }
- }
- .footer {
- margin-top: 20px;
- text-align: center;
- color: var(--text-color);
- font-size: 0.8rem;
- transition: color 0.3s ease;
- display: flex;
- flex-direction: column;
- gap: 5px;
- }
- /* Keyboard shortcuts overlay */
- .keyboard-shortcuts {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: var(--overlay-bg);
- z-index: 1000;
- display: none;
- justify-content: center;
- align-items: center;
- transition: background-color 0.3s ease;
- }
- .keyboard-shortcuts.active {
- display: flex;
- }
- .shortcuts-content {
- background-color: var(--bg-color);
- border-radius: 10px;
- padding: 20px;
- max-width: 90%;
- width: 400px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
- position: relative;
- transition: background-color 0.3s ease;
- }
- .shortcuts-title {
- font-size: 1.5rem;
- font-weight: bold;
- margin-bottom: 15px;
- color: var(--title-color);
- transition: color 0.3s ease;
- }
- .shortcut-grid {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 10px 20px;
- margin-bottom: 20px;
- }
- .key {
- background-color: var(--container-bg);
- color: var(--text-color);
- padding: 5px 10px;
- border-radius: 5px;
- font-family: monospace;
- text-align: center;
- font-weight: bold;
- transition: background-color 0.3s ease, color 0.3s ease;
- }
- .close-shortcuts {
- position: absolute;
- top: 10px;
- right: 10px;
- background: none;
- border: none;
- font-size: 1.5rem;
- cursor: pointer;
- color: var(--text-color);
- transition: transform 0.3s ease, color 0.3s ease;
- }
- .close-shortcuts:hover {
- transform: rotate(90deg);
- }
- /* Animation controls */
- .animation-toggle {
- border: none;
- background: none;
- cursor: pointer;
- font-size: 1.2rem;
- color: var(--text-color);
- transition: color 0.3s ease;
- display: flex;
- align-items: center;
- gap: 5px;
- }
- .animation-toggle:hover {
- color: var(--title-color);
- }
- .animation-status {
- font-size: 0.8rem;
- }
- /* Toast notification */
- .toast {
- position: fixed;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
- background-color: var(--container-bg);
- color: var(--text-color);
- padding: 10px 20px;
- border-radius: 5px;
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
- z-index: 1000;
- transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease, color 0.3s ease;
- opacity: 0;
- transform: translate(-50%, 20px);
- pointer-events: none;
- }
- .toast.active {
- opacity: 1;
- transform: translate(-50%, 0);
- }
- /* Accessibility focus styles */
- button:focus-visible, a:focus-visible {
- outline: 3px solid #4c9aff;
- outline-offset: 2px;
- }
- /* Skip link for keyboard users */
- .skip-link {
- position: absolute;
- top: -40px;
- left: 0;
- background: var(--button-bg);
- color: var(--button-text);
- padding: 8px;
- z-index: 100;
- transition: top 0.3s ease;
- }
- .skip-link:focus {
- top: 0;
- }
- </style>
- </head>
- <body>
- <a href="#game-container" class="skip-link">Skip to game</a>
- <div class="container">
- <header class="header">
- <div class="title-container">
- <h1 class="title">2048</h1>
- <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode">🌓</button>
- </div>
- <div class="scores">
- <div class="score-box">
- <div class="score-title">Score</div>
- <div class="score-value" id="score" aria-live="polite">0</div>
- </div>
- <div class="score-box">
- <div class="score-title">Best</div>
- <div class="score-value" id="best-score">0</div>
- </div>
- </div>
- </header>
- <div class="sub-header">
- <p class="game-intro">Join tiles, get to <strong>2048!</strong> Use <strong>arrow keys</strong> or <strong>swipe</strong>.</p>
- <div class="button-container">
- <button class="btn" id="restart-button" aria-label="New Game">New</button>
- <button class="btn" id="undo-button" aria-label="Undo Move" disabled>↩️</button>
- <button class="btn" id="help-button" aria-label="Show Keyboard Shortcuts">?</button>
- </div>
- </div>
- <div class="game-container" id="game-container" tabindex="0">
- <div class="grid-background" id="grid-background">
- <!-- Background cells are generated here by JS -->
- </div>
- <div class="tile-container" id="tile-container">
- <!-- Tiles are added here by JS -->
- </div>
- <div class="game-message" id="game-message" aria-live="assertive">
- <p id="message-text"></p>
- <div class="lower">
- <button class="btn" id="retry-button">Try again</button>
- <button class="btn" id="keep-playing-button" style="display: none;">Keep Playing</button>
- </div>
- </div>
- </div>
- <div class="stats-container" id="stats-container">
- <div class="stats-title">
- Game Statistics
- <button class="stats-toggle" id="stats-toggle" aria-label="Toggle statistics visibility">▼</button>
- </div>
- <div class="stats-content" id="stats-content">
- <div class="stats-row">
- <span>Games Played:</span>
- <span class="stats-value" id="games-played">0</span>
- </div>
- <div class="stats-row">
- <span>Highest Tile:</span>
- <span class="stats-value" id="highest-tile">0</span>
- </div>
- <div class="stats-row">
- <span>Total Moves:</span>
- <span class="stats-value" id="total-moves">0</span>
- </div>
- <div class="stats-row">
- <span>Total Score:</span>
- <span class="stats-value" id="total-score">0</span>
- </div>
- <div class="stats-row">
- <span>Avg Score/Game:</span>
- <span class="stats-value" id="avg-score">0</span>
- </div>
- </div>
- </div>
- <footer class="footer">
- <div class="animation-controls">
- <button class="animation-toggle" id="animation-toggle">
- <span class="btn-icon">✨</span>
- <span class="animation-status" id="animation-status">Animations: On</span>
- </button>
- </div>
- <p>Advanced Edition • <a href="#" id="keyboard-link">Keyboard shortcuts</a></p>
- </footer>
- </div>
- <div class="keyboard-shortcuts" id="keyboard-shortcuts">
- <div class="shortcuts-content">
- <button class="close-shortcuts" id="close-shortcuts" aria-label="Close keyboard shortcuts">×</button>
- <h2 class="shortcuts-title">Keyboard Shortcuts</h2>
- <div class="shortcut-grid">
- <div class="key">↑</div><div>Move Up</div>
- <div class="key">↓</div><div>Move Down</div>
- <div class="key">←</div><div>Move Left</div>
- <div class="key">→</div><div>Move Right</div>
- <div class="key">W</div><div>Move Up</div>
- <div class="key">S</div><div>Move Down</div>
- <div class="key">A</div><div>Move Left</div>
- <div class="key">D</div><div>Move Right</div>
- <div class="key">R</div><div>Restart Game</div>
- <div class="key">U</div><div>Undo Move</div>
- <div class="key">Z</div><div>Undo Move</div>
- <div class="key">M</div><div>Toggle Dark Mode</div>
- <div class="key">Esc</div><div>Close Dialogs</div>
- <div class="key">?</div><div>Show This Help</div>
- </div>
- </div>
- </div>
- <div class="toast" id="toast"></div>
- <script>
- document.addEventListener('DOMContentLoaded', () => {
- const GRID_SIZE = 4;
- const WINNING_VALUE = 2048;
- const MOVE_ANIMATION_DURATION = 150; // ms, matches CSS --animation-speed
- const APPEAR_ANIMATION_DURATION = 200; // ms, matches CSS --appear-speed
- const MAX_UNDO_STEPS = 20; // Maximum number of moves to store in history
- // --- DOM Elements ---
- const gameContainer = document.getElementById('game-container');
- const gridBackground = document.getElementById('grid-background');
- const tileContainer = document.getElementById('tile-container');
- const scoreElement = document.getElementById('score');
- const bestScoreElement = document.getElementById('best-score');
- const messageContainer = document.getElementById('game-message');
- const messageText = document.getElementById('message-text');
- const restartButton = document.getElementById('restart-button');
- const retryButton = document.getElementById('retry-button');
- const keepPlayingButton = document.getElementById('keep-playing-button');
- const undoButton = document.getElementById('undo-button');
- const themeToggle = document.getElementById('theme-toggle');
- const helpButton = document.getElementById('help-button');
- const keyboardShortcuts = document.getElementById('keyboard-shortcuts');
- const closeShortcuts = document.getElementById('close-shortcuts');
- const keyboardLink = document.getElementById('keyboard-link');
- const toast = document.getElementById('toast');
- const statsToggle = document.getElementById('stats-toggle');
- const statsContent = document.getElementById('stats-content');
- const gamesPlayedElement = document.getElementById('games-played');
- const highestTileElement = document.getElementById('highest-tile');
- const totalMovesElement = document.getElementById('total-moves');
- const totalScoreElement = document.getElementById('total-score');
- const avgScoreElement = document.getElementById('avg-score');
- const animationToggle = document.getElementById('animation-toggle');
- const animationStatus = document.getElementById('animation-status');
- // --- Game State ---
- let grid; // Will be a 2D array representing cells (Tile objects or null)
- let score = 0;
- let bestScore = parseInt(localStorage.getItem('bestScore2048') || 0);
- let isGameOver = false;
- let hasWon = false; // Tracks if 2048 has been reached
- let keepPlayingMode = false; // Tracks if user chose to continue after winning
- let isMoving = false; // Prevent input during animations
- let gameHistory = []; // Array to store game states for undo
- let animationsEnabled = localStorage.getItem('animationsEnabled') !== 'false'; // Default to true
- let darkMode = localStorage.getItem('darkMode') === 'true' ||
- (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && localStorage.getItem('darkMode') !== 'false');
- // --- Statistics ---
- let stats = {
- gamesPlayed: 0,
- highestTile: 0,
- totalMoves: 0,
- totalScore: 0
- };
- // --- Touch Input State ---
- let touchStartX = 0;
- let touchStartY = 0;
- let touchEndX = 0;
- let touchEndY = 0;
- // --- Tile Class ---
- class Tile {
- constructor(value, row, col) {
- this.value = value;
- this.row = row;
- this.col = col;
- this.element = this.createElement();
- this.updatePosition(false); // Initial position without animation
- this.mergedFrom = null; // Track merge source for animation coordination
- tileContainer.appendChild(this.element);
- }
- createElement() {
- const tileElement = document.createElement('div');
- tileElement.classList.add('tile');
- this.updateValue(this.value); // Set initial text and data-value
- // Add new tile animation class if animations are enabled
- if (animationsEnabled) {
- tileElement.classList.add('new-tile');
- setTimeout(() => {
- tileElement.classList.remove('new-tile');
- }, APPEAR_ANIMATION_DURATION);
- }
- return tileElement;
- }
- updateValue(newValue) {
- this.value = newValue;
- this.element.textContent = newValue;
- this.element.dataset.value = newValue; // For CSS styling
- // Update statistics if needed
- if (newValue > stats.highestTile) {
- stats.highestTile = newValue;
- saveStats();
- updateStatsDisplay();
- }
- }
- updatePosition(animate = true) {
- const { top, left } = this.calculatePixelPosition();
- // Only enable transitions if animations are enabled
- this.element.style.transition = (animate && animationsEnabled)
- ? `top ${MOVE_ANIMATION_DURATION}ms ease-in-out, left ${MOVE_ANIMATION_DURATION}ms ease-in-out`
- : 'none';
- this.element.style.top = `${top}px`;
- this.element.style.left = `${left}px`;
- }
- calculatePixelPosition() {
- // Get these values dynamically in case they change (e.g., responsive)
- const cellSize = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-size'));
- const cellGap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-gap'));
- const top = this.row * (cellSize + cellGap) + cellGap;
- const left = this.col * (cellSize + cellGap) + cellGap;
- return { top, left };
- }
- remove(merged = false) {
- if (merged && animationsEnabled) {
- // If merged and animations enabled, let the pop animation finish before removing
- this.element.classList.add('merged-tile');
- this.element.addEventListener('animationend', () => {
- if (this.element.parentNode) {
- tileContainer.removeChild(this.element);
- }
- }, { once: true });
- } else {
- // Otherwise, remove immediately
- if (this.element.parentNode) {
- tileContainer.removeChild(this.element);
- }
- }
- }
- playMergeAnimation() {
- if (animationsEnabled) {
- this.element.classList.add('merged-tile');
- // Remove class after animation to allow re-triggering
- this.element.addEventListener('animationend', () => {
- this.element.classList.remove('merged-tile');
- }, { once: true });
- }
- }
- // Clone for history/undo
- clone() {
- return {
- value: this.value,
- row: this.row,
- col: this.col
- };
- }
- }
- // --- Grid Initialization ---
- function createGridCells() {
- gridBackground.innerHTML = ''; // Clear previous cells
- for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
- const cell = document.createElement('div');
- cell.classList.add('cell');
- gridBackground.appendChild(cell);
- }
- }
- function initializeGrid() {
- grid = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- }
- // --- Game Setup ---
- function setupGame() {
- isMoving = true; // Prevent input during setup
- initializeGrid();
- tileContainer.innerHTML = ''; // Clear existing tiles
- updateScore(0);
- bestScoreElement.textContent = bestScore;
- isGameOver = false;
- hasWon = false;
- keepPlayingMode = false;
- hideMessage();
- gameHistory = []; // Clear history
- updateUndoButton();
- // Increment games played on new game (but not on first load)
- if (tileContainer.childElementCount > 0) {
- stats.gamesPlayed++;
- saveStats();
- updateStatsDisplay();
- }
- addRandomTile();
- addRandomTile();
- // Save initial state to history
- saveGameState();
- isMoving = false; // Allow input
- }
- // --- Score Management ---
- function updateScore(newScore, addition = 0) {
- const oldScore = score;
- score = newScore;
- scoreElement.textContent = score;
- // Show score addition animation
- if (addition > 0 && animationsEnabled) {
- const scoreAdditionElement = document.createElement('div');
- scoreAdditionElement.classList.add('score-addition');
- scoreAdditionElement.textContent = `+${addition}`;
- // Position near the score display
- const scoreRect = scoreElement.getBoundingClientRect();
- scoreAdditionElement.style.top = `${scoreRect.top - 10}px`;
- scoreAdditionElement.style.left = `${scoreRect.left + scoreRect.width / 2}px`;
- document.body.appendChild(scoreAdditionElement);
- // Remove after animation completes
- setTimeout(() => {
- if (scoreAdditionElement.parentNode) {
- document.body.removeChild(scoreAdditionElement);
- }
- }, 800);
- }
- // Update best score if needed
- if (score > bestScore) {
- bestScore = score;
- bestScoreElement.textContent = bestScore;
- localStorage.setItem('bestScore2048', bestScore);
- }
- // Update total score in stats
- stats.totalScore += (newScore - oldScore);
- saveStats();
- updateStatsDisplay();
- }
- // --- Tile Management ---
- function getEmptyCells() {
- const emptyCells = [];
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c] === null) {
- emptyCells.push({ r, c });
- }
- }
- }
- return emptyCells;
- }
- function addRandomTile() {
- const emptyCells = getEmptyCells();
- if (emptyCells.length === 0) return; // No space left
- const { r, c } = emptyCells[Math.floor(Math.random() * emptyCells.length)];
- const value = Math.random() < 0.9 ? 2 : 4;
- const newTile = new Tile(value, r, c);
- grid[r][c] = newTile;
- }
- // --- Game State Management for Undo ---
- function saveGameState() {
- // Create a deep copy of the current grid state
- const gridCopy = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c] !== null) {
- gridCopy[r][c] = grid[r][c].clone();
- }
- }
- }
- // Add to history, limiting size
- gameHistory.push({
- grid: gridCopy,
- score: score
- });
- // Keep history at a reasonable size
- if (gameHistory.length > MAX_UNDO_STEPS) {
- gameHistory.shift();
- }
- // Enable/disable undo button
- updateUndoButton();
- }
- function updateUndoButton() {
- undoButton.disabled = gameHistory.length <= 1;
- }
- function undoMove() {
- if (isMoving || gameHistory.length <= 1) return;
- isMoving = true;
- // Remove the current state
- gameHistory.pop();
- // Get the previous state
- const previousState = gameHistory[gameHistory.length - 1];
- // Clear current tiles
- tileContainer.innerHTML = '';
- // Restore grid and create new tile elements
- initializeGrid();
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (previousState.grid[r][c] !== null) {
- const { value, row, col } = previousState.grid[r][c];
- grid[r][c] = new Tile(value, row, col);
- }
- }
- }
- // Restore score
- updateScore(previousState.score);
- // Update undo button state
- updateUndoButton();
- // Re-enable input
- isMoving = false;
- // Increment total moves in stats
- stats.totalMoves++;
- saveStats();
- updateStatsDisplay();
- // Show toast notification
- showToast("Move undone");
- }
- // --- Movement Logic ---
- function move(direction) {
- if (isMoving || isGameOver) return;
- isMoving = true;
- let moved = false;
- let currentScore = score; // Track score changes from this move
- let scoreAddition = 0; // Track score added in this move
- // Prepare for move: reset merge tracking
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c]) grid[r][c].mergedFrom = null;
- }
- }
- // Define traversal order based on direction
- const traversals = getTraversals(direction);
- traversals.rows.forEach(r => {
- traversals.cols.forEach(c => {
- const currentTile = grid[r][c];
- if (currentTile) {
- const { furthestRow, furthestCol, nextTile } = findFurthestPosition(r, c, direction);
- if (nextTile && nextTile.value === currentTile.value && !nextTile.mergedFrom) {
- // --- Merge ---
- const mergedValue = currentTile.value * 2;
- nextTile.updateValue(mergedValue);
- nextTile.mergedFrom = currentTile; // Mark as merged
- nextTile.playMergeAnimation(); // Play pop animation on target
- grid[r][c] = null; // Remove current tile from grid
- currentTile.remove(true); // Remove current tile element (with animation flag)
- scoreAddition += mergedValue;
- currentScore += mergedValue;
- moved = true;
- // Check for win condition immediately after merge
- if (!hasWon && mergedValue === WINNING_VALUE) {
- hasWon = true;
- }
- } else if (furthestRow !== r || furthestCol !== c) {
- // --- Move ---
- grid[furthestRow][furthestCol] = currentTile;
- grid[r][c] = null;
- currentTile.row = furthestRow;
- currentTile.col = furthestCol;
- currentTile.updatePosition(); // Animate move
- moved = true;
- }
- }
- });
- });
- if (moved) {
- // Save game state before adding the new tile
- saveGameState();
- // Update score display with animation
- updateScore(currentScore, scoreAddition);
- // Increment total moves in stats
- stats.totalMoves++;
- saveStats();
- updateStatsDisplay();
- // Use setTimeout to allow move/merge animations to mostly complete
- const delay = animationsEnabled ? MOVE_ANIMATION_DURATION + 50 : 0;
- setTimeout(() => {
- addRandomTile();
- if (!canMove()) {
- checkGameOver();
- } else if (hasWon && !keepPlayingMode) {
- showWinMessage();
- }
- isMoving = false; // Re-enable input
- }, delay);
- } else {
- isMoving = false; // No move occurred, re-enable input immediately
- }
- return moved;
- }
- function getTraversals(direction) {
- const rows = Array.from({ length: GRID_SIZE }, (_, i) => i);
- const cols = Array.from({ length: GRID_SIZE }, (_, i) => i);
- if (direction === 'right') cols.reverse();
- if (direction === 'down') rows.reverse();
- return { rows, cols };
- }
- function findFurthestPosition(r, c, direction) {
- let currentRow = r;
- let currentCol = c;
- let nextRow, nextCol;
- do {
- currentRow = nextRow !== undefined ? nextRow : r;
- currentCol = nextCol !== undefined ? nextCol : c;
- const { nr, nc } = getNextPosition(currentRow, currentCol, direction);
- nextRow = nr;
- nextCol = nc;
- } while (isWithinBounds(nextRow, nextCol) && grid[nextRow][nextCol] === null);
- // The loop went one step too far or hit a tile
- const furthestRow = currentRow;
- const furthestCol = currentCol;
- const nextTile = isWithinBounds(nextRow, nextCol) ? grid[nextRow][nextCol] : null;
- return { furthestRow, furthestCol, nextTile };
- }
- function getNextPosition(r, c, direction) {
- switch (direction) {
- case 'up': return { nr: r - 1, nc: c };
- case 'down': return { nr: r + 1, nc: c };
- case 'left': return { nr: r, nc: c - 1 };
- case 'right': return { nr: r, nc: c + 1 };
- default: return { nr: r, nc: c };
- }
- }
- function isWithinBounds(r, c) {
- return r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE;
- }
- // --- Game Status Checks ---
- function canMove() {
- if (getEmptyCells().length > 0) return true; // Can always move if empty cells exist
- // Check for possible merges horizontally and vertically
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- const tile = grid[r][c];
- if (!tile) continue; // Should not happen if no empty cells, but safe check
- // Check right
- if (c < GRID_SIZE - 1 && grid[r][c + 1] && grid[r][c + 1].value === tile.value) {
- return true;
- }
- // Check down
- if (r < GRID_SIZE - 1 && grid[r + 1][c] && grid[r + 1][c].value === tile.value) {
- return true;
- }
- }
- }
- return false; // No empty cells and no possible merges
- }
- function checkGameOver() {
- if (!isGameOver && !canMove()) {
- isGameOver = true;
- showGameOverMessage();
- }
- }
- // --- Message Display ---
- function showMessage(text, type) {
- messageText.textContent = text;
- messageContainer.className = 'game-message active'; // Reset classes and add active
- keepPlayingButton.style.display = 'none'; // Hide by default
- if (type === 'win') {
- messageContainer.classList.add('game-won');
- if (!keepPlayingMode) {
- keepPlayingButton.style.display = 'inline-block'; // Show keep playing
- }
- } else if (type === 'over') {
- messageContainer.classList.add('game-over');
- }
- }
- function showWinMessage() {
- showMessage('You Win!', 'win');
- }
- function showGameOverMessage() {
- showMessage('Game Over!', 'over');
- }
- function hideMessage() {
- messageContainer.classList.remove('active', 'game-won', 'game-over');
- }
- // --- Toast Notification ---
- function showToast(message, duration = 2000) {
- toast.textContent = message;
- toast.classList.add('active');
- // Clear any existing timeout
- if (toast.timeoutId) {
- clearTimeout(toast.timeoutId);
- }
- // Hide after duration
- toast.timeoutId = setTimeout(() => {
- toast.classList.remove('active');
- }, duration);
- }
- // --- Theme Management ---
- function toggleTheme() {
- darkMode = !darkMode;
- updateTheme();
- localStorage.setItem('darkMode', darkMode);
- showToast(darkMode ? 'Dark mode enabled' : 'Light mode enabled');
- }
- function updateTheme() {
- document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
- }
- // --- Animation Management ---
- function toggleAnimations() {
- animationsEnabled = !animationsEnabled;
- updateAnimationStatus();
- localStorage.setItem('animationsEnabled', animationsEnabled);
- showToast(animationsEnabled ? 'Animations enabled' : 'Animations disabled');
- }
- function updateAnimationStatus() {
- animationStatus.textContent = `Animations: ${animationsEnabled ? 'On' : 'Off'}`;
- }
- // --- Statistics Management ---
- function loadStats() {
- const savedStats = localStorage.getItem('gameStats2048');
- if (savedStats) {
- try {
- stats = JSON.parse(savedStats);
- } catch (e) {
- console.error('Error parsing saved statistics:', e);
- // Use default stats if parse error
- }
- }
- }
- function saveStats() {
- localStorage.setItem('gameStats2048', JSON.stringify(stats));
- }
- function updateStatsDisplay() {
- gamesPlayedElement.textContent = stats.gamesPlayed;
- highestTileElement.textContent = stats.highestTile;
- totalMovesElement.textContent = stats.totalMoves;
- totalScoreElement.textContent = stats.totalScore;
- // Calculate average score per game
- const avgScore = stats.gamesPlayed > 0
- ? Math.round(stats.totalScore / stats.gamesPlayed)
- : 0;
- avgScoreElement.textContent = avgScore;
- }
- function toggleStats() {
- statsContent.classList.toggle('expanded');
- statsToggle.textContent = statsContent.classList.contains('expanded') ? '▲' : '▼';
- }
- // --- Keyboard Shortcuts Help ---
- function showKeyboardShortcuts() {
- keyboardShortcuts.classList.add('active');
- }
- function hideKeyboardShortcuts() {
- keyboardShortcuts.classList.remove('active');
- }
- // --- Event Listeners ---
- function handleKeyDown(e) {
- if (isMoving) return;
- // Close keyboard shortcuts dialog with Escape
- if (e.key === 'Escape' && keyboardShortcuts.classList.contains('active')) {
- hideKeyboardShortcuts();
- e.preventDefault();
- return;
- }
- // Game is over, only allow restart or undo
- if (isGameOver) {
- if (e.key === 'r' || e.key === 'R') {
- setupGame();
- e.preventDefault();
- } else if ((e.key === 'u' || e.key === 'U' || e.key === 'z' || e.key === 'Z') && !undoButton.disabled) {
- undoMove();
- e.preventDefault();
- }
- return;
- }
- switch (e.key) {
- case 'ArrowUp':
- case 'w':
- case 'W':
- e.preventDefault(); // Prevent page scrolling
- move('up');
- break;
- case 'ArrowDown':
- case 's':
- case 'S':
- e.preventDefault();
- move('down');
- break;
- case 'ArrowLeft':
- case 'a':
- case 'A':
- e.preventDefault();
- move('left');
- break;
- case 'ArrowRight':
- case 'd':
- case 'D':
- e.preventDefault();
- move('right');
- break;
- case 'r':
- case 'R':
- e.preventDefault();
- setupGame();
- break;
- case 'u':
- case 'U':
- case 'z':
- case 'Z':
- e.preventDefault();
- if (!undoButton.disabled) {
- undoMove();
- }
- break;
- case 'm':
- case 'M':
- e.preventDefault();
- toggleTheme();
- break;
- case '?':
- e.preventDefault();
- showKeyboardShortcuts();
- break;
- }
- }
- function handleTouchStart(e) {
- if (isMoving || e.touches.length > 1) return; // Ignore multi-touch
- touchStartX = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- touchEndX = touchStartX; // Reset end points
- touchEndY = touchStartY;
- }
- function handleTouchMove(e) {
- if (isMoving || e.touches.length > 1) return;
- // Update end points for continuous tracking if needed, but mainly for the final calculation
- touchEndX = e.touches[0].clientX;
- touchEndY = e.touches[0].clientY;
- e.preventDefault(); // Prevent scrolling *during* swipe detection
- }
- function handleTouchEnd(e) {
- if (isMoving || e.changedTouches.length > 1) return;
- const deltaX = touchEndX - touchStartX;
- const deltaY = touchEndY - touchStartY;
- const absDeltaX = Math.abs(deltaX);
- const absDeltaY = Math.abs(deltaY);
- const swipeThreshold = 30; // Minimum distance for a swipe
- if (Math.max(absDeltaX, absDeltaY) < swipeThreshold) return; // Not a swipe
- let direction;
- if (absDeltaX > absDeltaY) {
- // Horizontal swipe
- direction = deltaX > 0 ? 'right' : 'left';
- } else {
- // Vertical swipe
- direction = deltaY > 0 ? 'down' : 'up';
- }
- move(direction);
- }
- // --- Game Container Focus Management ---
- function handleGameContainerFocus() {
- // Add a visual indicator or enhance accessibility when game container is focused
- gameContainer.classList.add('focused');
- }
- function handleGameContainerBlur() {
- gameContainer.classList.remove('focused');
- }
- // --- Save/Load Game State ---
- function saveGame() {
- // Create a saveable representation of the current game state
- const saveData = {
- grid: Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null)),
- score: score,
- bestScore: bestScore,
- hasWon: hasWon,
- keepPlayingMode: keepPlayingMode
- };
- // Save tile values and positions
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (grid[r][c] !== null) {
- saveData.grid[r][c] = grid[r][c].clone();
- }
- }
- }
- localStorage.setItem('savedGame2048', JSON.stringify(saveData));
- }
- function loadGame() {
- const savedGame = localStorage.getItem('savedGame2048');
- if (!savedGame) return false;
- try {
- const saveData = JSON.parse(savedGame);
- // Reset game state
- initializeGrid();
- tileContainer.innerHTML = '';
- // Restore score and game state
- score = saveData.score;
- scoreElement.textContent = score;
- bestScore = saveData.bestScore;
- bestScoreElement.textContent = bestScore;
- hasWon = saveData.hasWon;
- keepPlayingMode = saveData.keepPlayingMode;
- // Restore tiles
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (saveData.grid[r][c] !== null) {
- const { value, row, col } = saveData.grid[r][c];
- grid[r][c] = new Tile(value, row, col);
- }
- }
- }
- // Save initial state to history
- saveGameState();
- // Check game status
- if (!canMove()) {
- isGameOver = true;
- showGameOverMessage();
- } else if (hasWon && !keepPlayingMode) {
- showWinMessage();
- }
- return true;
- } catch (e) {
- console.error('Error loading saved game:', e);
- return false;
- }
- }
- // --- Window Events ---
- function handleVisibilityChange() {
- if (document.visibilityState === 'hidden') {
- // Save game when tab is hidden/inactive
- saveGame();
- }
- }
- function handleBeforeUnload() {
- // Save game when page is closed/refreshed
- saveGame();
- }
- // --- Initialize ---
- function initialize() {
- // Set up theme
- updateTheme();
- // Set up animation status
- updateAnimationStatus();
- // Create grid cells
- createGridCells();
- // Load statistics
- loadStats();
- updateStatsDisplay();
- // Try to load saved game, or start a new one
- const gameLoaded = loadGame();
- if (!gameLoaded) {
- setupGame();
- }
- // Add event listeners
- document.addEventListener('keydown', handleKeyDown);
- // Game container event listeners
- gameContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
- gameContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
- gameContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
- gameContainer.addEventListener('focus', handleGameContainerFocus);
- gameContainer.addEventListener('blur', handleGameContainerBlur);
- // Button event listeners
- restartButton.addEventListener('click', setupGame);
- retryButton.addEventListener('click', setupGame);
- undoButton.addEventListener('click', undoMove);
- keepPlayingButton.addEventListener('click', () => {
- keepPlayingMode = true;
- hideMessage();
- isMoving = false; // Ensure input is re-enabled
- });
- themeToggle.addEventListener('click', toggleTheme);
- helpButton.addEventListener('click', showKeyboardShortcuts);
- closeShortcuts.addEventListener('click', hideKeyboardShortcuts);
- keyboardLink.addEventListener('click', (e) => {
- e.preventDefault();
- showKeyboardShortcuts();
- });
- statsToggle.addEventListener('click', toggleStats);
- animationToggle.addEventListener('click', toggleAnimations);
- // Window event listeners
- document.addEventListener('visibilitychange', handleVisibilityChange);
- window.addEventListener('beforeunload', handleBeforeUnload);
- }
- // Start the game
- initialize();
- });
- </script>
- </body>
- </html>
- ```
- ## Key Enhancements Explained
- ### 1. Game History and Undo Functionality
- - Added a `gameHistory` array to store previous game states
- - Implemented `saveGameState()` to capture a deep copy of the grid and score
- - Created `undoMove()` to restore a previous state
- - Added keyboard shortcuts (U/Z) for undo
- - Limited history to `MAX_UNDO_STEPS` to prevent memory issues
- ### 2. Dark Mode Support
- - Added CSS variables for all colors with light/dark variants
- - Implemented theme toggle with localStorage persistence
- - Added system preference detection via `prefers-color-scheme`
- - Smooth transitions between themes
- ### 3. Game State Persistence
- - Added `saveGame()` and `loadGame()` functions
- - Game state is saved when tab becomes inactive or before page unload
- - Game state is loaded on startup if available
- ### 4. Statistics Tracking
- - Added statistics panel with toggle
- - Tracks games played, highest tile, total moves, total score, average score
- - Statistics persist in localStorage
- ### 5. Keyboard and Touch Accessibility
- - Added keyboard shortcuts help dialog
- - Improved keyboard navigation support
- - Added a skip link for keyboard users
- - Enhanced focus styles for better visibility
- - Added ARIA attributes for screen readers
- ### 6. Animation Control
- - Added toggle to enable/disable animations
- - Persists preference in localStorage
- - Improves performance on lower-end devices
- ### 7. Visual and UX Enhancements
- - Added score addition animation (+100 popup)
- - Added toast notifications for user feedback
- - Improved responsive design
- - Added transition effects for smoother experience
- - Support for larger tiles (beyond 2048)
- ### 8. Performance Optimizations
- - Added `will-change` hints for better rendering performance
- - Conditional animation based on user preference
- - More efficient DOM manipulation
- - Optimized event handling
- ### 9. Code Structure Improvements
- - Better organization of related functions
- - More consistent naming conventions
- - Added comments for better maintainability
- - Improved error handling
- ## Conclusion
- This enhanced version of 2048 builds on Gemini's excellent refactoring by adding several quality-of-life features and optimizations. The game is now more accessible, customizable, and user-friendly while maintaining the core gameplay experience. The addition of undo functionality, game state persistence, and statistics tracking adds depth to the experience, while the performance optimizations and animation controls ensure it runs well on a wide range of devices.
- ## Gemini (Iteration 3)
- Okay, Claude, that's a fantastic enhancement! You've added a wealth of features that significantly improve the game's usability, longevity, and polish. The dark mode, undo, persistence, stats, and accessibility additions are all excellent.
- I've reviewed your code and will now build upon it, focusing primarily on architectural improvements for better organization and maintainability, along with some minor refinements.
- ## Analysis of Claude's Implementation
- **Strengths:**
- * **Feature Completeness:** You've implemented all the requested features effectively (undo, stats, persistence, dark mode, animation control, etc.).
- * **Robustness:** Includes error handling for loading, persistence triggers (`visibilitychange`, `beforeunload`), and history limits.
- * **User Experience:** Toast notifications, score animations, clear visual states (win/lose), and responsive design create a polished feel.
- * **Accessibility:** Good use of ARIA, keyboard navigation, focus management, and skip links.
- * **Theming:** Excellent use of CSS variables for easy theming and dark mode.
- * **`Tile` Class:** The `Tile` class remains a good abstraction for managing individual tiles.
- **Areas for Architectural Improvement:**
- * **Global Scope Pollution:** Many variables and functions reside in the global scope (within the DOMContentLoaded listener). This can make the code harder to manage as it grows.
- * **Encapsulation:** Game logic, state, and UI updates are somewhat intertwined in the main script scope. Encapsulating these concerns would improve organization.
- * **Animation Synchronization:** While using `setTimeout` after moves is common, it relies on fixed durations. A more robust approach could use Promises or animation events for better synchronization, although the current method is often sufficient.
- * **Readability:** Some functions like `move` and `initialize` are quite long.
- ## My Refinements: Introducing a `Game` Class
- My main contribution will be to refactor the core game logic and state into a `Game` class. This addresses the global scope and encapsulation issues, making the code more modular and easier to understand.
- **Key Changes:**
- 1. **`Game` Class:** Encapsulates game state (`grid`, `score`, `bestScore`, `history`, `stats`, `settings`, etc.) and core logic methods (`move`, `undo`, `addRandomTile`, `saveState`, `loadState`, `checkStatus`, etc.).
- 2. **UI Interaction:** The `Game` class will interact with a dedicated `UIManager` (or directly with DOM elements passed during construction) to update the display. For simplicity here, I'll have the `Game` class manage its required DOM elements directly.
- 3. **Constants:** Grouped constants for better clarity.
- 4. **Event Handling:** Event listeners will primarily call methods on the `Game` instance.
- 5. **Minor Refinements:** Added constants for magic numbers, slightly improved comments, basic localStorage error handling. Clarified the `totalMoves` statistic name/comment.
- ```html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>2048 - Collaborative Edition</title>
- <style>
- /* CSS remains largely the same as Claude's version */
- /* ... (Claude's CSS styles - no major changes needed here) ... */
- :root {
- --grid-size: 4;
- --cell-size: 100px;
- --cell-gap: 12px;
- --border-radius: 8px;
- --animation-speed: 150ms; /* Slightly increased for smoother feel */
- --appear-speed: 200ms;
- --pop-speed: 200ms;
- /* Color scheme - light mode (default) */
- --bg-color: #faf8ef;
- --container-bg: #bbada0;
- --cell-bg: rgba(238, 228, 218, 0.35);
- --title-color: #776e65;
- --text-color: #776e65;
- --button-bg: #8f7a66;
- --button-hover-bg: #9f8b77;
- --button-text: #f9f6f2;
- --overlay-bg: rgba(238, 228, 218, 0.73);
- --tile-2-bg: #eee4da;
- --tile-2-text: #776e65;
- --tile-4-bg: #ede0c8;
- --tile-4-text: #776e65;
- --tile-8-bg: #f2b179;
- --tile-8-text: #f9f6f2;
- --tile-16-bg: #f59563;
- --tile-16-text: #f9f6f2;
- --tile-32-bg: #f67c5f;
- --tile-32-text: #f9f6f2;
- --tile-64-bg: #f65e3b;
- --tile-64-text: #f9f6f2;
- --tile-128-bg: #edcf72;
- --tile-128-text: #f9f6f2;
- --tile-256-bg: #edcc61;
- --tile-256-text: #f9f6f2;
- --tile-512-bg: #edc850;
- --tile-512-text: #f9f6f2;
- --tile-1024-bg: #edc53f;
- --tile-1024-text: #f9f6f2;
- --tile-2048-bg: #edc22e;
- --tile-2048-text: #f9f6f2;
- --tile-super-bg: #3c3a32;
- --tile-super-text: #f9f6f2;
- }
- /* Dark mode color scheme */
- [data-theme="dark"] {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- /* Other tile colors remain the same for recognition */
- }
- @media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- }
- }
- * { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Arial', sans-serif; }
- body { background-color: var(--bg-color); color: var(--text-color); display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; overflow-y: auto; touch-action: none; padding: 10px; transition: background-color 0.3s ease, color 0.3s ease; }
- .container { width: min(95vw, 500px); padding: 15px; border-radius: 10px; background-color: var(--container-bg); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); transition: background-color 0.3s ease; }
- .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
- .title-container { display: flex; align-items: center; gap: 10px; }
- .title { color: var(--title-color); font-size: clamp(2rem, 8vw, 2.5rem); font-weight: bold; transition: color 0.3s ease; }
- .theme-toggle { background: none; border: none; cursor: pointer; font-size: 1.5rem; color: var(--text-color); transition: transform 0.3s ease; padding: 5px; margin-left: 10px; }
- .theme-toggle:hover { transform: rotate(30deg); }
- .scores { display: flex; gap: 10px; }
- .score-box { background-color: var(--button-bg); color: var(--button-text); padding: 8px 12px; border-radius: 5px; display: flex; flex-direction: column; align-items: center; min-width: 70px; text-align: center; transition: background-color 0.3s ease; }
- .score-title { font-size: 0.7rem; text-transform: uppercase; margin-bottom: 2px; }
- .score-value { font-size: clamp(1.2rem, 5vw, 1.5rem); font-weight: bold; }
- .sub-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
- .game-intro { color: var(--text-color); flex-basis: 60%; font-size: 0.9rem; line-height: 1.3; transition: color 0.3s ease; }
- .button-container { display: flex; gap: 8px; flex-wrap: wrap; }
- .btn { background-color: var(--button-bg); color: var(--button-text); border: none; border-radius: 5px; padding: 10px 15px; font-weight: bold; cursor: pointer; transition: background-color 0.2s ease, transform 0.1s ease; white-space: nowrap; display: flex; align-items: center; justify-content: center; min-width: 40px; }
- .btn:hover, .btn:focus { background-color: var(--button-hover-bg); outline: none; }
- .btn:active { transform: scale(0.95); }
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
- .btn-icon { font-size: 1.2rem; line-height: 1; }
- .game-container { position: relative; width: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap)); height: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap)); background-color: var(--container-bg); border-radius: var(--border-radius); padding: var(--cell-gap); margin: 0 auto; overflow: hidden; transition: background-color 0.3s ease; }
- .game-container.focused { outline: 3px solid #4c9aff; outline-offset: 3px; } /* Focus outline for game container */
- .grid-background { position: absolute; top: var(--cell-gap); left: var(--cell-gap); bottom: var(--cell-gap); right: var(--cell-gap); display: grid; grid-template-columns: repeat(var(--grid-size), var(--cell-size)); grid-template-rows: repeat(var(--grid-size), var(--cell-size)); gap: var(--cell-gap); }
- .cell { background-color: var(--cell-bg); border-radius: var(--border-radius); transition: background-color 0.3s ease; }
- .tile-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 5;} /* Ensure tiles are above grid */
- .tile { position: absolute; display: flex; justify-content: center; align-items: center; width: var(--cell-size); height: var(--cell-size); border-radius: var(--border-radius); font-size: clamp(1.5rem, 7vw, 2rem); font-weight: bold; background-color: var(--tile-2-bg); color: var(--tile-2-text); transition: top var(--animation-speed) ease-in-out, left var(--animation-speed) ease-in-out, background-color 0.3s ease, color 0.3s ease; z-index: 10; user-select: none; will-change: transform, opacity; }
- .tile.new-tile { animation: appear var(--appear-speed) ease-in-out; }
- @keyframes appear { 0% { opacity: 0; transform: scale(0); } 100% { opacity: 1; transform: scale(1); } }
- .tile.merged-tile { animation: pop var(--pop-speed) ease-in-out; z-index: 20; }
- @keyframes pop { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } }
- .tile[data-value="2"] { background-color: var(--tile-2-bg); color: var(--tile-2-text); }
- .tile[data-value="4"] { background-color: var(--tile-4-bg); color: var(--tile-4-text); }
- .tile[data-value="8"] { background-color: var(--tile-8-bg); color: var(--tile-8-text); }
- .tile[data-value="16"] { background-color: var(--tile-16-bg); color: var(--tile-16-text); }
- .tile[data-value="32"] { background-color: var(--tile-32-bg); color: var(--tile-32-text); }
- .tile[data-value="64"] { background-color: var(--tile-64-bg); color: var(--tile-64-text); }
- .tile[data-value="128"] { background-color: var(--tile-128-bg); color: var(--tile-128-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="256"] { background-color: var(--tile-256-bg); color: var(--tile-256-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="512"] { background-color: var(--tile-512-bg); color: var(--tile-512-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="1024"] { background-color: var(--tile-1024-bg); color: var(--tile-1024-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="2048"] { background-color: var(--tile-2048-bg); color: var(--tile-2048-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="4096"], .tile[data-value="8192"], .tile[data-value="16384"], .tile[data-value="32768"], .tile[data-value="65536"] { background-color: var(--tile-super-bg); color: var(--tile-super-text); font-size: clamp(1rem, 4.5vw, 1.4rem); }
- .game-message { display: none; position: absolute; top: 0; right: 0; bottom: 0; left: 0; background-color: var(--overlay-bg); flex-direction: column; align-items: center; justify-content: center; text-align: center; border-radius: var(--border-radius); z-index: 100; opacity: 0; transition: opacity 0.3s ease-in-out, background-color 0.3s ease; animation: fadein 0.3s ease-in-out forwards; }
- .game-message.active { display: flex; opacity: 1; }
- @keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
- .game-message p { font-size: clamp(1.8rem, 7vw, 2rem); font-weight: bold; color: var(--title-color); margin-bottom: 20px; transition: color 0.3s ease; }
- .game-message .lower { display: flex; gap: 10px; margin-top: 10px; }
- .score-addition { position: fixed; /* Use fixed for positioning relative to viewport */ animation: score-pop 0.8s ease-in-out forwards; font-weight: bold; color: var(--title-color); z-index: 100; pointer-events: none; font-size: 1.2rem; opacity: 0; transition: color 0.3s ease; }
- @keyframes score-pop { 0% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, -30px); } } /* Adjusted transform */
- .stats-container { margin-top: 10px; background-color: var(--container-bg); border-radius: 8px; padding: 10px 15px; display: flex; flex-direction: column; gap: 5px; width: 100%; transition: background-color 0.3s ease; }
- .stats-title { font-weight: bold; color: var(--title-color); font-size: 1.1rem; margin-bottom: 5px; display: flex; justify-content: space-between; align-items: center; transition: color 0.3s ease; }
- .stats-row { display: flex; justify-content: space-between; font-size: 0.9rem; color: var(--text-color); transition: color 0.3s ease; }
- .stats-value { font-weight: bold; }
- .stats-toggle { font-size: 1rem; cursor: pointer; background: none; border: none; color: var(--text-color); transition: transform 0.3s ease, color 0.3s ease; }
- .stats-toggle:hover { transform: rotate(180deg); }
- .stats-content { overflow: hidden; max-height: 0; transition: max-height 0.3s ease-in-out; }
- .stats-content.expanded { max-height: 200px; /* Adjust based on content */ }
- @media screen and (max-width: 520px) { :root { --cell-size: 65px; --cell-gap: 8px; } .game-intro { flex-basis: 100%; text-align: center; margin-bottom: 10px; } .btn { padding: 8px 12px; } .button-container { justify-content: center; width: 100%; } }
- @media screen and (max-width: 380px) { :root { --cell-size: 55px; --cell-gap: 6px; } .container { padding: 10px; } .header { justify-content: center; } .title { text-align: center; width: 100%; margin-bottom: 5px;} .sub-header { justify-content: center; } .score-box { min-width: 60px; padding: 6px 10px; } .button-container { flex-wrap: wrap; } }
- .footer { margin-top: 20px; text-align: center; color: var(--text-color); font-size: 0.8rem; transition: color 0.3s ease; display: flex; flex-direction: column; gap: 5px; align-items: center;}
- .animation-controls { display: flex; align-items: center; gap: 5px; }
- .animation-toggle { border: none; background: none; cursor: pointer; font-size: 1.2rem; color: var(--text-color); transition: color 0.3s ease; display: flex; align-items: center; gap: 5px; }
- .animation-toggle:hover { color: var(--title-color); }
- .animation-status { font-size: 0.8rem; }
- .keyboard-shortcuts { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--overlay-bg); z-index: 1000; display: none; justify-content: center; align-items: center; transition: background-color 0.3s ease; }
- .keyboard-shortcuts.active { display: flex; }
- .shortcuts-content { background-color: var(--bg-color); border-radius: 10px; padding: 20px; max-width: 90%; width: 400px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); position: relative; transition: background-color 0.3s ease; }
- .shortcuts-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 15px; color: var(--title-color); transition: color 0.3s ease; }
- .shortcut-grid { display: grid; grid-template-columns: auto 1fr; gap: 10px 20px; margin-bottom: 20px; color: var(--text-color); } /* Added text color */
- .key { background-color: var(--container-bg); color: var(--text-color); padding: 5px 10px; border-radius: 5px; font-family: monospace; text-align: center; font-weight: bold; transition: background-color 0.3s ease, color 0.3s ease; }
- .close-shortcuts { position: absolute; top: 10px; right: 10px; background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--text-color); transition: transform 0.3s ease, color 0.3s ease; }
- .close-shortcuts:hover { transform: rotate(90deg); }
- .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: var(--container-bg); color: var(--text-color); padding: 10px 20px; border-radius: 5px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); z-index: 1000; transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease, color 0.3s ease; opacity: 0; transform: translate(-50%, 20px); pointer-events: none; }
- .toast.active { opacity: 1; transform: translate(-50%, 0); }
- button:focus-visible, a:focus-visible, [tabindex="0"]:focus-visible { outline: 3px solid #4c9aff; outline-offset: 2px; } /* Added game container focus */
- .skip-link { position: absolute; top: -40px; left: 0; background: var(--button-bg); color: var(--button-text); padding: 8px; z-index: 100; transition: top 0.3s ease; }
- .skip-link:focus { top: 0; }
- </style>
- </head>
- <body>
- <a href="#game-container" class="skip-link">Skip to game</a>
- <div class="container">
- <header class="header">
- <div class="title-container">
- <h1 class="title">2048</h1>
- <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode">🌓</button>
- </div>
- <div class="scores">
- <div class="score-box">
- <div class="score-title">Score</div>
- <div class="score-value" id="score" aria-live="polite">0</div>
- </div>
- <div class="score-box">
- <div class="score-title">Best</div>
- <div class="score-value" id="best-score">0</div>
- </div>
- </div>
- </header>
- <div class="sub-header">
- <p class="game-intro">Join tiles, get to <strong>2048!</strong> Use <strong>arrow keys</strong> or <strong>swipe</strong>.</p>
- <div class="button-container">
- <button class="btn" id="restart-button" aria-label="New Game">New</button>
- <button class="btn" id="undo-button" aria-label="Undo Move" disabled>↩️</button>
- <button class="btn" id="help-button" aria-label="Show Keyboard Shortcuts">?</button>
- </div>
- </div>
- <div class="game-container" id="game-container" tabindex="0" aria-label="Game Board">
- <div class="grid-background" id="grid-background"></div>
- <div class="tile-container" id="tile-container"></div>
- <div class="game-message" id="game-message" aria-live="assertive">
- <p id="message-text"></p>
- <div class="lower">
- <button class="btn" id="retry-button">Try again</button>
- <button class="btn" id="keep-playing-button" style="display: none;">Keep Playing</button>
- </div>
- </div>
- </div>
- <div class="stats-container" id="stats-container">
- <div class="stats-title">
- Game Statistics
- <button class="stats-toggle" id="stats-toggle" aria-label="Toggle statistics visibility" aria-expanded="false">▼</button>
- </div>
- <div class="stats-content" id="stats-content">
- <div class="stats-row"><span>Games Played:</span><span class="stats-value" id="games-played">0</span></div>
- <div class="stats-row"><span>Highest Tile:</span><span class="stats-value" id="highest-tile">0</span></div>
- <div class="stats-row"><span>Total Moves Made:</span><span class="stats-value" id="total-moves">0</span></div>
- <div class="stats-row"><span>Total Score Accumulated:</span><span class="stats-value" id="total-score">0</span></div>
- <div class="stats-row"><span>Avg Score/Game:</span><span class="stats-value" id="avg-score">0</span></div>
- </div>
- </div>
- <footer class="footer">
- <div class="animation-controls">
- <button class="animation-toggle" id="animation-toggle" aria-pressed="true">
- <span class="btn-icon">✨</span>
- <span class="animation-status" id="animation-status">Animations: On</span>
- </button>
- </div>
- <p>Collaborative Edition • <a href="#" id="keyboard-link">Keyboard shortcuts</a></p>
- </footer>
- </div>
- <div class="keyboard-shortcuts" id="keyboard-shortcuts" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title">
- <div class="shortcuts-content">
- <button class="close-shortcuts" id="close-shortcuts" aria-label="Close keyboard shortcuts">×</button>
- <h2 class="shortcuts-title" id="shortcuts-title">Keyboard Shortcuts</h2>
- <div class="shortcut-grid">
- <div class="key">↑ ↓ ← →</div><div>Move Tiles</div>
- <div class="key">W A S D</div><div>Move Tiles</div>
- <div class="key">R</div><div>Restart Game</div>
- <div class="key">U / Z</div><div>Undo Move</div>
- <div class="key">M</div><div>Toggle Dark Mode</div>
- <div class="key">T</div><div>Toggle Animations</div>
- <div class="key">Esc</div><div>Close Dialogs</div>
- <div class="key">?</div><div>Show This Help</div>
- </div>
- </div>
- </div>
- <div class="toast" id="toast" aria-live="assertive"></div>
- <script>
- document.addEventListener('DOMContentLoaded', () => {
- // --- Constants ---
- const GRID_SIZE = 4;
- const WINNING_VALUE = 2048;
- const MOVE_ANIMATION_DURATION = 150; // ms, matches CSS --animation-speed
- const APPEAR_ANIMATION_DURATION = 200; // ms, matches CSS --appear-speed
- const POP_ANIMATION_DURATION = 200; // ms, matches CSS --pop-speed
- const MAX_UNDO_STEPS = 20;
- const SWIPE_THRESHOLD = 30; // Min distance for swipe
- const LOCAL_STORAGE_KEYS = {
- BEST_SCORE: 'bestScore2048Collab',
- GAME_STATE: 'savedGame2048Collab',
- STATS: 'gameStats2048Collab',
- DARK_MODE: 'darkModeCollab',
- ANIMATIONS: 'animationsEnabledCollab'
- };
- // --- Tile Class (Remains largely the same as Claude's) ---
- class Tile {
- constructor(value, row, col, parentContainer, settingsRef) {
- this.value = value;
- this.row = row;
- this.col = col;
- this.parentContainer = parentContainer;
- this.settings = settingsRef; // Reference to game settings (like animationsEnabled)
- this.element = this.createElement();
- this.updatePosition(false); // Initial position without animation
- this.mergedFrom = null; // Track merge source
- this.parentContainer.appendChild(this.element);
- }
- createElement() {
- const tileElement = document.createElement('div');
- tileElement.classList.add('tile');
- this.updateValue(this.value); // Set initial text and data-value
- if (this.settings.animationsEnabled) {
- tileElement.classList.add('new-tile');
- // No need for timeout removal here, CSS animation handles visibility
- }
- return tileElement;
- }
- updateValue(newValue) {
- this.value = newValue;
- this.element.textContent = newValue;
- this.element.dataset.value = newValue; // For CSS styling
- }
- updatePosition(animate = true) {
- const { top, left } = this.calculatePixelPosition();
- // Use transition only if animations are enabled AND requested
- this.element.style.transition = (animate && this.settings.animationsEnabled)
- ? `top ${MOVE_ANIMATION_DURATION}ms ease-in-out, left ${MOVE_ANIMATION_DURATION}ms ease-in-out`
- : 'none';
- this.element.style.top = `${top}px`;
- this.element.style.left = `${left}px`;
- }
- calculatePixelPosition() {
- const cellSize = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-size'));
- const cellGap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-gap'));
- const top = this.row * (cellSize + cellGap) + cellGap;
- const left = this.col * (cellSize + cellGap) + cellGap;
- return { top, left };
- }
- remove(merged = false) {
- // Use transitionend for move animations, animationend for pop/appear
- const removeAction = () => {
- if (this.element.parentNode) {
- this.parentContainer.removeChild(this.element);
- }
- };
- if (merged && this.settings.animationsEnabled) {
- // Let pop animation finish
- this.element.addEventListener('animationend', removeAction, { once: true });
- } else {
- // If not merged or animations off, remove immediately or after move transition
- if(this.settings.animationsEnabled && this.element.style.transition !== 'none') {
- this.element.addEventListener('transitionend', removeAction, {once: true});
- } else {
- removeAction();
- }
- }
- }
- playMergeAnimation() {
- if (this.settings.animationsEnabled) {
- this.element.classList.add('merged-tile');
- this.element.addEventListener('animationend', () => {
- this.element.classList.remove('merged-tile');
- }, { once: true });
- }
- }
- clone() {
- return { value: this.value, row: this.row, col: this.col };
- }
- }
- // --- Game Class ---
- class Game {
- constructor(elements) {
- this.elements = elements; // Store references to DOM elements
- this.grid = [];
- this.score = 0;
- this.bestScore = 0;
- this.isGameOver = false;
- this.hasWon = false;
- this.keepPlayingMode = false;
- this.isMoving = false;
- this.gameHistory = [];
- this.stats = { gamesPlayed: 0, highestTile: 0, totalMoves: 0, totalScore: 0 };
- this.settings = {
- animationsEnabled: true,
- darkMode: false
- };
- // Touch state needs to be part of the instance
- this.touchStartX = 0;
- this.touchStartY = 0;
- this.touchEndX = 0;
- this.touchEndY = 0;
- }
- // --- Initialization and Setup ---
- init() {
- this.loadSettings();
- this.loadStats();
- this.loadBestScore();
- this.updateTheme();
- this.updateAnimationStatus();
- this.createGridCells();
- this.setupEventHandlers();
- if (!this.loadGame()) {
- this.startNewGame(false); // Don't increment stats on initial load
- }
- this.updateUI();
- }
- startNewGame(incrementStats = true) {
- this.isMoving = true; // Prevent input during setup
- this.initializeGrid();
- this.elements.tileContainer.innerHTML = ''; // Clear existing tiles
- this.score = 0;
- this.isGameOver = false;
- this.hasWon = false;
- this.keepPlayingMode = false;
- this.hideMessage();
- this.gameHistory = []; // Clear history
- if (incrementStats) {
- this.stats.gamesPlayed++;
- this.saveStats();
- }
- this.addRandomTile();
- this.addRandomTile();
- this.saveGameState(); // Save initial state
- this.updateUI();
- this.isMoving = false; // Allow input
- }
- initializeGrid() {
- this.grid = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- }
- createGridCells() {
- this.elements.gridBackground.innerHTML = ''; // Clear previous cells
- for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
- const cell = document.createElement('div');
- cell.classList.add('cell');
- this.elements.gridBackground.appendChild(cell);
- }
- }
- // --- State Management (Save/Load) ---
- saveSettings() {
- try {
- localStorage.setItem(LOCAL_STORAGE_KEYS.DARK_MODE, this.settings.darkMode);
- localStorage.setItem(LOCAL_STORAGE_KEYS.ANIMATIONS, this.settings.animationsEnabled);
- } catch (e) {
- console.error("Error saving settings to localStorage:", e);
- this.showToast("Could not save settings.");
- }
- }
- loadSettings() {
- const darkModeSaved = localStorage.getItem(LOCAL_STORAGE_KEYS.DARK_MODE);
- const animationsSaved = localStorage.getItem(LOCAL_STORAGE_KEYS.ANIMATIONS);
- // Determine initial dark mode
- this.settings.darkMode = darkModeSaved === 'true' ||
- (darkModeSaved === null && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
- // Determine initial animation setting
- this.settings.animationsEnabled = animationsSaved !== 'false'; // Default true
- }
- loadBestScore() {
- this.bestScore = parseInt(localStorage.getItem(LOCAL_STORAGE_KEYS.BEST_SCORE) || 0);
- }
- saveBestScore() {
- try {
- localStorage.setItem(LOCAL_STORAGE_KEYS.BEST_SCORE, this.bestScore);
- } catch (e) {
- console.error("Error saving best score to localStorage:", e);
- }
- }
- loadStats() {
- const savedStats = localStorage.getItem(LOCAL_STORAGE_KEYS.STATS);
- if (savedStats) {
- try {
- this.stats = JSON.parse(savedStats);
- } catch (e) {
- console.error('Error parsing saved statistics:', e);
- this.stats = { gamesPlayed: 0, highestTile: 0, totalMoves: 0, totalScore: 0 }; // Reset if corrupt
- }
- }
- }
- saveStats() {
- try {
- localStorage.setItem(LOCAL_STORAGE_KEYS.STATS, JSON.stringify(this.stats));
- } catch (e) {
- console.error("Error saving stats to localStorage:", e);
- }
- }
- saveGame() {
- if (this.isGameOver) { // Don't save finished games
- localStorage.removeItem(LOCAL_STORAGE_KEYS.GAME_STATE);
- return;
- }
- const gridSaveData = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (this.grid[r][c]) {
- gridSaveData[r][c] = this.grid[r][c].clone();
- }
- }
- }
- const saveData = {
- grid: gridSaveData,
- score: this.score,
- hasWon: this.hasWon,
- keepPlayingMode: this.keepPlayingMode,
- // No need to save bestScore here, it's saved separately
- };
- try {
- localStorage.setItem(LOCAL_STORAGE_KEYS.GAME_STATE, JSON.stringify(saveData));
- } catch (e) {
- console.error("Error saving game state to localStorage:", e);
- this.showToast("Could not save game progress.");
- }
- }
- loadGame() {
- const savedGame = localStorage.getItem(LOCAL_STORAGE_KEYS.GAME_STATE);
- if (!savedGame) return false;
- try {
- const saveData = JSON.parse(savedGame);
- this.initializeGrid();
- this.elements.tileContainer.innerHTML = '';
- this.score = saveData.score;
- this.hasWon = saveData.hasWon;
- this.keepPlayingMode = saveData.keepPlayingMode;
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (saveData.grid[r][c]) {
- const { value, row, col } = saveData.grid[r][c];
- // Pass parent container and settings reference to Tile constructor
- this.grid[r][c] = new Tile(value, row, col, this.elements.tileContainer, this.settings);
- }
- }
- }
- this.saveGameState(); // Save loaded state as the first step in history
- this.checkGameStatus(); // Check win/loss state after loading
- return true;
- } catch (e) {
- console.error('Error loading saved game:', e);
- localStorage.removeItem(LOCAL_STORAGE_KEYS.GAME_STATE); // Clear corrupted data
- return false;
- }
- }
- // --- Game Logic ---
- addRandomTile() {
- const emptyCells = this.getEmptyCells();
- if (emptyCells.length === 0) return;
- const { r, c } = emptyCells[Math.floor(Math.random() * emptyCells.length)];
- const value = Math.random() < 0.9 ? 2 : 4;
- // Pass parent container and settings reference to Tile constructor
- const newTile = new Tile(value, r, c, this.elements.tileContainer, this.settings);
- this.grid[r][c] = newTile;
- // Update highest tile stat if needed
- if (value > this.stats.highestTile) {
- this.stats.highestTile = value;
- this.saveStats();
- this.updateStatsDisplay();
- }
- }
- getEmptyCells() {
- const emptyCells = [];
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (this.grid[r][c] === null) {
- emptyCells.push({ r, c });
- }
- }
- }
- return emptyCells;
- }
- move(direction) {
- if (this.isMoving || this.isGameOver) return false;
- this.isMoving = true;
- let moved = false;
- let scoreAddition = 0;
- const initialScore = this.score;
- // Reset merge tracking
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (this.grid[r][c]) this.grid[r][c].mergedFrom = null;
- }
- }
- const traversals = this.getTraversals(direction);
- traversals.rows.forEach(r => {
- traversals.cols.forEach(c => {
- const currentTile = this.grid[r][c];
- if (currentTile) {
- const { furthestRow, furthestCol, nextTile } = this.findFurthestPosition(r, c, direction);
- if (nextTile && nextTile.value === currentTile.value && !nextTile.mergedFrom) {
- // --- Merge ---
- const mergedValue = currentTile.value * 2;
- nextTile.updateValue(mergedValue);
- nextTile.mergedFrom = currentTile;
- nextTile.playMergeAnimation();
- this.grid[r][c] = null;
- currentTile.remove(true); // Remove with merge flag
- scoreAddition += mergedValue;
- moved = true;
- if (!this.hasWon && mergedValue === WINNING_VALUE) {
- this.hasWon = true;
- }
- if (mergedValue > this.stats.highestTile) {
- this.stats.highestTile = mergedValue;
- this.saveStats(); // Save stats immediately on update
- }
- } else if (furthestRow !== r || furthestCol !== c) {
- // --- Move ---
- this.grid[furthestRow][furthestCol] = currentTile;
- this.grid[r][c] = null;
- currentTile.row = furthestRow;
- currentTile.col = furthestCol;
- currentTile.updatePosition(); // Animate move
- moved = true;
- }
- }
- });
- });
- if (moved) {
- this.updateScore(this.score + scoreAddition, scoreAddition);
- this.stats.totalMoves++; // Increment moves made in this session/lifetime
- this.saveStats();
- this.saveGameState(); // Save state *before* adding new tile
- const delay = this.settings.animationsEnabled ? MOVE_ANIMATION_DURATION + 50 : 0;
- setTimeout(() => {
- this.addRandomTile();
- this.checkGameStatus();
- this.updateUI(); // Update score, buttons etc.
- this.isMoving = false;
- }, delay);
- } else {
- this.isMoving = false; // No move occurred
- }
- return moved; // Return whether a move happened
- }
- getTraversals(direction) {
- const rows = Array.from({ length: GRID_SIZE }, (_, i) => i);
- const cols = Array.from({ length: GRID_SIZE }, (_, i) => i);
- if (direction === 'right') cols.reverse();
- if (direction === 'down') rows.reverse();
- return { rows, cols };
- }
- findFurthestPosition(r, c, direction) {
- let currentRow = r, currentCol = c, nextRow, nextCol;
- do {
- currentRow = nextRow !== undefined ? nextRow : r;
- currentCol = nextCol !== undefined ? nextCol : c;
- const { nr, nc } = this.getNextPosition(currentRow, currentCol, direction);
- nextRow = nr;
- nextCol = nc;
- } while (this.isWithinBounds(nextRow, nextCol) && this.grid[nextRow][nextCol] === null);
- const furthestRow = currentRow;
- const furthestCol = currentCol;
- const nextTile = this.isWithinBounds(nextRow, nextCol) ? this.grid[nextRow][nextCol] : null;
- return { furthestRow, furthestCol, nextTile };
- }
- getNextPosition(r, c, direction) {
- switch (direction) {
- case 'up': return { nr: r - 1, nc: c };
- case 'down': return { nr: r + 1, nc: c };
- case 'left': return { nr: r, nc: c - 1 };
- case 'right': return { nr: r, nc: c + 1 };
- default: return { nr: r, nc: c };
- }
- }
- isWithinBounds(r, c) {
- return r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE;
- }
- canMove() {
- if (this.getEmptyCells().length > 0) return true;
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- const tile = this.grid[r][c];
- if (!tile) continue;
- // Check right and down - sufficient to check all adjacencies
- if (c < GRID_SIZE - 1 && this.grid[r][c + 1]?.value === tile.value) return true;
- if (r < GRID_SIZE - 1 && this.grid[r + 1][c]?.value === tile.value) return true;
- }
- }
- return false;
- }
- checkGameStatus() {
- if (this.isGameOver) return; // Already over
- const canMove = this.canMove();
- if (this.hasWon && !this.keepPlayingMode) {
- this.showMessage('You Win!', 'win');
- // Don't set isGameOver, allow keep playing
- } else if (!canMove) {
- this.isGameOver = true;
- this.showMessage('Game Over!', 'over');
- // Clear saved game state as the game is finished
- localStorage.removeItem(LOCAL_STORAGE_KEYS.GAME_STATE);
- }
- }
- // --- History and Undo ---
- saveGameState() {
- const gridCopy = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(null));
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (this.grid[r][c]) {
- gridCopy[r][c] = this.grid[r][c].clone();
- }
- }
- }
- this.gameHistory.push({ grid: gridCopy, score: this.score });
- if (this.gameHistory.length > MAX_UNDO_STEPS) {
- this.gameHistory.shift();
- }
- this.updateUndoButton();
- }
- undoMove() {
- if (this.isMoving || this.gameHistory.length <= 1) return;
- this.isMoving = true;
- this.hideMessage(); // Hide any win/loss message if undoing
- this.isGameOver = false; // Game is no longer over if we undo
- this.gameHistory.pop(); // Remove current state
- const previousState = this.gameHistory[this.gameHistory.length - 1];
- this.elements.tileContainer.innerHTML = ''; // Clear display
- this.initializeGrid(); // Clear logical grid
- for (let r = 0; r < GRID_SIZE; r++) {
- for (let c = 0; c < GRID_SIZE; c++) {
- if (previousState.grid[r][c]) {
- const { value, row, col } = previousState.grid[r][c];
- // Restore tile object - pass container and settings
- this.grid[r][c] = new Tile(value, row, col, this.elements.tileContainer, this.settings);
- }
- }
- }
- // Restore score *without* triggering score addition animation/stat update
- this.score = previousState.score;
- this.elements.scoreElement.textContent = this.score;
- // Note: We don't decrement totalMoves or totalScore stats on undo.
- // These stats track lifetime activity.
- this.updateUI(); // Update buttons, etc.
- this.showToast("Move undone");
- this.isMoving = false;
- }
- // --- UI Updates ---
- updateUI() {
- this.updateScoreDisplay();
- this.updateBestScoreDisplay();
- this.updateUndoButton();
- this.updateStatsDisplay();
- // Ensure game message state is correct (e.g., hidden if not win/loss)
- if (!this.isGameOver && !(this.hasWon && !this.keepPlayingMode)) {
- this.hideMessage();
- }
- }
- updateScore(newScore, addition = 0) {
- const oldScore = this.score;
- this.score = newScore;
- this.updateScoreDisplay();
- if (addition > 0 && this.settings.animationsEnabled) {
- this.showScoreAddition(addition);
- }
- if (this.score > this.bestScore) {
- this.bestScore = this.score;
- this.saveBestScore();
- this.updateBestScoreDisplay();
- }
- // Update total score stat only if score increased
- if(newScore > oldScore) {
- this.stats.totalScore += (newScore - oldScore);
- this.saveStats();
- this.updateStatsDisplay(); // Update display if stats changed
- }
- }
- updateScoreDisplay() {
- this.elements.scoreElement.textContent = this.score;
- }
- updateBestScoreDisplay() {
- this.elements.bestScoreElement.textContent = this.bestScore;
- }
- updateUndoButton() {
- this.elements.undoButton.disabled = this.gameHistory.length <= 1 || this.isMoving;
- }
- updateStatsDisplay() {
- this.elements.gamesPlayedElement.textContent = this.stats.gamesPlayed;
- this.elements.highestTileElement.textContent = this.stats.highestTile;
- this.elements.totalMovesElement.textContent = this.stats.totalMoves;
- this.elements.totalScoreElement.textContent = this.stats.totalScore;
- const avgScore = this.stats.gamesPlayed > 0
- ? Math.round(this.stats.totalScore / this.stats.gamesPlayed)
- : 0;
- this.elements.avgScoreElement.textContent = avgScore;
- }
- showMessage(text, type) {
- this.elements.messageText.textContent = text;
- this.elements.messageContainer.className = 'game-message active'; // Reset classes
- this.elements.keepPlayingButton.style.display = 'none';
- if (type === 'win') {
- this.elements.messageContainer.classList.add('game-won');
- if (!this.keepPlayingMode) {
- this.elements.keepPlayingButton.style.display = 'inline-block';
- }
- } else if (type === 'over') {
- this.elements.messageContainer.classList.add('game-over');
- }
- }
- hideMessage() {
- this.elements.messageContainer.classList.remove('active', 'game-won', 'game-over');
- }
- showScoreAddition(addition) {
- const scoreAdditionElement = document.createElement('div');
- scoreAdditionElement.classList.add('score-addition');
- scoreAdditionElement.textContent = `+${addition}`;
- const scoreRect = this.elements.scoreElement.getBoundingClientRect();
- // Position relative to viewport using fixed positioning
- scoreAdditionElement.style.position = 'fixed';
- scoreAdditionElement.style.top = `${scoreRect.top - 10}px`; // Position above score box
- scoreAdditionElement.style.left = `${scoreRect.left + scoreRect.width / 2}px`; // Centered horizontally
- document.body.appendChild(scoreAdditionElement);
- scoreAdditionElement.addEventListener('animationend', () => {
- if (scoreAdditionElement.parentNode) {
- document.body.removeChild(scoreAdditionElement);
- }
- }, { once: true });
- }
- showToast(message, duration = 2000) {
- const toastElement = this.elements.toast;
- toastElement.textContent = message;
- toastElement.classList.add('active');
- if (toastElement.timeoutId) clearTimeout(toastElement.timeoutId);
- toastElement.timeoutId = setTimeout(() => {
- toastElement.classList.remove('active');
- }, duration);
- }
- // --- Settings Toggles ---
- toggleTheme() {
- this.settings.darkMode = !this.settings.darkMode;
- this.updateTheme();
- this.saveSettings();
- this.showToast(this.settings.darkMode ? 'Dark mode enabled' : 'Light mode enabled');
- }
- updateTheme() {
- document.documentElement.setAttribute('data-theme', this.settings.darkMode ? 'dark' : 'light');
- }
- toggleAnimations() {
- this.settings.animationsEnabled = !this.settings.animationsEnabled;
- this.updateAnimationStatus();
- this.saveSettings();
- this.showToast(this.settings.animationsEnabled ? 'Animations enabled' : 'Animations disabled');
- }
- updateAnimationStatus() {
- this.elements.animationStatus.textContent = `Animations: ${this.settings.animationsEnabled ? 'On' : 'Off'}`;
- this.elements.animationToggle.setAttribute('aria-pressed', this.settings.animationsEnabled);
- }
- toggleStats() {
- const isExpanded = this.elements.statsContent.classList.toggle('expanded');
- this.elements.statsToggle.textContent = isExpanded ? '▲' : '▼';
- this.elements.statsToggle.setAttribute('aria-expanded', isExpanded);
- }
- showKeyboardShortcuts() {
- this.elements.keyboardShortcuts.classList.add('active');
- this.elements.closeShortcuts.focus(); // Focus the close button
- }
- hideKeyboardShortcuts() {
- this.elements.keyboardShortcuts.classList.remove('active');
- this.elements.helpButton.focus(); // Return focus to the help button
- }
- // --- Event Handlers ---
- setupEventHandlers() {
- // Keyboard
- document.addEventListener('keydown', this.handleKeyDown.bind(this));
- // Touch
- this.elements.gameContainer.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
- this.elements.gameContainer.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
- this.elements.gameContainer.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
- // Focus Management for Game Container
- this.elements.gameContainer.addEventListener('focus', () => this.elements.gameContainer.classList.add('focused'));
- this.elements.gameContainer.addEventListener('blur', () => this.elements.gameContainer.classList.remove('focused'));
- // Buttons
- this.elements.restartButton.addEventListener('click', () => this.startNewGame());
- this.elements.retryButton.addEventListener('click', () => this.startNewGame());
- this.elements.undoButton.addEventListener('click', () => this.undoMove());
- this.elements.keepPlayingButton.addEventListener('click', () => {
- this.keepPlayingMode = true;
- this.hideMessage();
- this.isMoving = false; // Ensure input enabled
- this.showToast("Keep playing!");
- });
- this.elements.themeToggle.addEventListener('click', () => this.toggleTheme());
- this.elements.helpButton.addEventListener('click', () => this.showKeyboardShortcuts());
- this.elements.closeShortcuts.addEventListener('click', () => this.hideKeyboardShortcuts());
- this.elements.keyboardLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showKeyboardShortcuts();
- });
- this.elements.statsToggle.addEventListener('click', () => this.toggleStats());
- this.elements.animationToggle.addEventListener('click', () => this.toggleAnimations());
- // Window events for saving state
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'hidden') this.saveGame();
- });
- window.addEventListener('beforeunload', () => this.saveGame());
- }
- handleKeyDown(e) {
- // Handle shortcuts dialog first
- if (this.elements.keyboardShortcuts.classList.contains('active')) {
- if (e.key === 'Escape') {
- e.preventDefault();
- this.hideKeyboardShortcuts();
- }
- return; // Don't process game keys if dialog is open
- }
- if (this.isMoving) return; // Ignore input during animation
- // Always allow help
- if (e.key === '?') {
- e.preventDefault();
- this.showKeyboardShortcuts();
- return;
- }
- // Always allow theme/animation toggle
- if (e.key === 'm' || e.key === 'M') {
- e.preventDefault();
- this.toggleTheme();
- return;
- }
- if (e.key === 't' || e.key === 'T') { // Added 'T' for toggle animations
- e.preventDefault();
- this.toggleAnimations();
- return;
- }
- // Game Over specific actions
- if (this.isGameOver) {
- if (e.key === 'r' || e.key === 'R') {
- e.preventDefault();
- this.startNewGame();
- }
- // Allow undo even when game over if possible
- else if ((e.key === 'u' || e.key === 'U' || e.key === 'z' || e.key === 'Z') && !this.elements.undoButton.disabled) {
- e.preventDefault();
- this.undoMove();
- }
- return; // No other actions when game over
- }
- // Active Game Actions
- let direction = null;
- switch (e.key) {
- case 'ArrowUp': case 'w': case 'W': direction = 'up'; break;
- case 'ArrowDown': case 's': case 'S': direction = 'down'; break;
- case 'ArrowLeft': case 'a': case 'A': direction = 'left'; break;
- case 'ArrowRight': case 'd': case 'D': direction = 'right'; break;
- case 'r': case 'R':
- e.preventDefault(); this.startNewGame(); return;
- case 'u': case 'U': case 'z': case 'Z':
- e.preventDefault(); if (!this.elements.undoButton.disabled) this.undoMove(); return;
- }
- if (direction) {
- e.preventDefault(); // Prevent scrolling
- this.move(direction);
- }
- }
- handleTouchStart(e) {
- if (this.isMoving || e.touches.length > 1) return;
- this.touchStartX = e.touches[0].clientX;
- this.touchStartY = e.touches[0].clientY;
- this.touchEndX = this.touchStartX;
- this.touchEndY = this.touchStartY;
- }
- handleTouchMove(e) {
- if (this.isMoving || e.touches.length > 1) return;
- this.touchEndX = e.touches[0].clientX;
- this.touchEndY = e.touches[0].clientY;
- // Prevent scrolling ONLY if a swipe is likely vertical/horizontal
- const deltaX = this.touchEndX - this.touchStartX;
- const deltaY = this.touchEndY - this.touchStartY;
- if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) { // Simple threshold
- e.preventDefault();
- }
- }
- handleTouchEnd(e) {
- if (this.isMoving || this.isGameOver || e.changedTouches.length > 1) return;
- const deltaX = this.touchEndX - this.touchStartX;
- const deltaY = this.touchEndY - this.touchStartY;
- const absDeltaX = Math.abs(deltaX);
- const absDeltaY = Math.abs(deltaY);
- if (Math.max(absDeltaX, absDeltaY) < SWIPE_THRESHOLD) return; // Not a swipe
- let direction = absDeltaX > absDeltaY
- ? (deltaX > 0 ? 'right' : 'left')
- : (deltaY > 0 ? 'down' : 'up');
- this.move(direction);
- }
- }
- // --- App Initialization ---
- const gameElements = {
- gameContainer: document.getElementById('game-container'),
- gridBackground: document.getElementById('grid-background'),
- tileContainer: document.getElementById('tile-container'),
- scoreElement: document.getElementById('score'),
- bestScoreElement: document.getElementById('best-score'),
- messageContainer: document.getElementById('game-message'),
- messageText: document.getElementById('message-text'),
- restartButton: document.getElementById('restart-button'),
- retryButton: document.getElementById('retry-button'),
- keepPlayingButton: document.getElementById('keep-playing-button'),
- undoButton: document.getElementById('undo-button'),
- themeToggle: document.getElementById('theme-toggle'),
- helpButton: document.getElementById('help-button'),
- keyboardShortcuts: document.getElementById('keyboard-shortcuts'),
- closeShortcuts: document.getElementById('close-shortcuts'),
- keyboardLink: document.getElementById('keyboard-link'),
- toast: document.getElementById('toast'),
- statsToggle: document.getElementById('stats-toggle'),
- statsContent: document.getElementById('stats-content'),
- gamesPlayedElement: document.getElementById('games-played'),
- highestTileElement: document.getElementById('highest-tile'),
- totalMovesElement: document.getElementById('total-moves'),
- totalScoreElement: document.getElementById('total-score'),
- avgScoreElement: document.getElementById('avg-score'),
- animationToggle: document.getElementById('animation-toggle'),
- animationStatus: document.getElementById('animation-status')
- };
- const game = new Game(gameElements);
- game.init();
- });
- </script>
- </body>
- </html>
- ```
- ## Explanation of Changes
- 1. **`Game` Class:**
- * The core logic and state are now encapsulated within the `Game` class.
- * DOM element references are passed into the constructor and stored in `this.elements`.
- * Game state variables (`grid`, `score`, `isGameOver`, `history`, `stats`, `settings`, etc.) are now properties of the `Game` instance (`this.score`, `this.settings.darkMode`, etc.).
- * All major functions (`move`, `undoMove`, `addRandomTile`, `saveGame`, `loadGame`, `updateScore`, `showMessage`, etc.) are now methods of the `Game` class.
- * Touch state variables (`touchStartX`, etc.) are also moved into the class instance to avoid global scope.
- 2. **Initialization (`Game.init`)**:
- * A new `init` method handles the setup sequence: loading settings/stats/scores, creating the grid, setting up event handlers, and loading a saved game or starting a new one.
- * This makes the initial `DOMContentLoaded` callback much cleaner, simply creating the `Game` instance and calling `init()`.
- 3. **Event Handling:**
- * Event listeners (for keys, buttons, touch, window events) now call methods on the `game` instance (e.g., `this.handleKeyDown.bind(this)`, `() => game.startNewGame()`). Using `.bind(this)` ensures that `this` inside the handler refers to the `Game` instance.
- 4. **`Tile` Class Modifications:**
- * The `Tile` constructor now accepts the `parentContainer` (the `#tile-container` div) and a reference to the `game.settings` object.
- * This allows the `Tile` instance to know where to append itself and to check `settings.animationsEnabled` directly when deciding whether to apply animation classes or transitions.
- 5. **Constants:** Defined constants for `SWIPE_THRESHOLD` and grouped `LOCAL_STORAGE_KEYS` for better maintainability and to avoid typos.
- 6. **UI Updates:** UI update methods (`updateScoreDisplay`, `updateBestScoreDisplay`, `updateUndoButton`, etc.) are now part of the `Game` class, clearly separating logic from presentation updates. A central `updateUI` method is called after actions to refresh relevant parts of the display.
- 7. **Persistence:** Save/load methods are now part of the `Game` class. Basic `try...catch` blocks are added around `localStorage` operations to prevent errors if storage is disabled or full, showing a toast message on failure. Saving now explicitly avoids saving completed games.
- 8. **Accessibility:** Added `aria-label` to the game board container. Added `aria-expanded` to the stats toggle button and `aria-pressed` to the animation toggle. Improved focus management for the keyboard shortcuts dialog. Added focus style for the game container itself.
- 9. **Clarity:** Renamed "Total Moves" statistic to "Total Moves Made" and "Total Score" to "Total Score Accumulated" for slightly better clarity. Added `T` as a keyboard shortcut for toggling animations.
- This refactoring into a `Game` class significantly improves the structure and maintainability of the code without drastically changing the features or core logic you implemented. It provides a solid foundation for any future enhancements.
- ## Claude (Iteration 3)
- # Enhanced 2048 Game - Final Collaborative Edition
- I've reviewed Gemini's excellent refactoring of our code, which significantly improved the architecture by introducing a proper `Game` class. This provides better encapsulation and organization. I'll now build upon this foundation to further enhance the game's architecture, fix some issues, and add new features.
- ## Key Improvements
- 1. **Architectural Refinements**
- - Add a proper `UIManager` class to separate UI concerns from game logic
- - Implement Promise-based animation handling
- - Add a `GridManager` class to handle grid operations
- - Improve event handling with a central event manager
- 2. **New Features**
- - Auto-save functionality with timestamps and named saves
- - Game replay functionality
- - Achievement system
- - Game board size options (3x3, 4x4, 5x5, 6x6)
- - Difficulty levels (affects probability of 4s vs 2s)
- 3. **Bug Fixes & Optimizations**
- - Fix animation synchronization issues
- - Improve touch handling
- - Optimize performance with better DOM management
- - Better error handling
- 4. **Accessibility & UX Improvements**
- - Enhanced keyboard navigation
- - Improved focus management
- - Better screen reader support
- - More descriptive game messages
- ## The Final Implementation
- Here's the enhanced version:
- ```html
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>2048 - Ultimate Edition</title>
- <style>
- :root {
- --grid-size: 4;
- --cell-size: 100px;
- --cell-gap: 12px;
- --border-radius: 8px;
- --animation-speed: 150ms;
- --appear-speed: 200ms;
- --pop-speed: 200ms;
- /* Color scheme - light mode (default) */
- --bg-color: #faf8ef;
- --container-bg: #bbada0;
- --cell-bg: rgba(238, 228, 218, 0.35);
- --title-color: #776e65;
- --text-color: #776e65;
- --button-bg: #8f7a66;
- --button-hover-bg: #9f8b77;
- --button-text: #f9f6f2;
- --overlay-bg: rgba(238, 228, 218, 0.73);
- --tile-2-bg: #eee4da;
- --tile-2-text: #776e65;
- --tile-4-bg: #ede0c8;
- --tile-4-text: #776e65;
- --tile-8-bg: #f2b179;
- --tile-8-text: #f9f6f2;
- --tile-16-bg: #f59563;
- --tile-16-text: #f9f6f2;
- --tile-32-bg: #f67c5f;
- --tile-32-text: #f9f6f2;
- --tile-64-bg: #f65e3b;
- --tile-64-text: #f9f6f2;
- --tile-128-bg: #edcf72;
- --tile-128-text: #f9f6f2;
- --tile-256-bg: #edcc61;
- --tile-256-text: #f9f6f2;
- --tile-512-bg: #edc850;
- --tile-512-text: #f9f6f2;
- --tile-1024-bg: #edc53f;
- --tile-1024-text: #f9f6f2;
- --tile-2048-bg: #edc22e;
- --tile-2048-text: #f9f6f2;
- --tile-super-bg: #3c3a32;
- --tile-super-text: #f9f6f2;
- --modal-bg: rgba(250, 248, 239, 0.95);
- --modal-text: #776e65;
- --modal-border: #bbada0;
- --achievement-bg: #8f7a66;
- --achievement-text: #f9f6f2;
- }
- /* Dark mode color scheme */
- [data-theme="dark"] {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- --modal-bg: rgba(31, 41, 55, 0.95);
- --modal-text: #e5e7eb;
- --modal-border: #4b5563;
- --achievement-bg: #4b5563;
- --achievement-text: #f9fafb;
- }
- @media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) {
- --bg-color: #1f2937;
- --container-bg: #374151;
- --cell-bg: rgba(55, 65, 81, 0.7);
- --title-color: #e5e7eb;
- --text-color: #e5e7eb;
- --button-bg: #4b5563;
- --button-hover-bg: #6b7280;
- --button-text: #f9fafb;
- --overlay-bg: rgba(31, 41, 55, 0.8);
- --tile-2-bg: #9ca3af;
- --tile-2-text: #1f2937;
- --tile-4-bg: #d1d5db;
- --tile-4-text: #1f2937;
- --modal-bg: rgba(31, 41, 55, 0.95);
- --modal-text: #e5e7eb;
- --modal-border: #4b5563;
- --achievement-bg: #4b5563;
- --achievement-text: #f9fafb;
- }
- }
- * {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
- font-family: 'Arial', sans-serif;
- }
- body {
- background-color: var(--bg-color);
- color: var(--text-color);
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- min-height: 100vh;
- overflow-y: auto;
- touch-action: none;
- padding: 10px;
- transition: background-color 0.3s ease, color 0.3s ease;
- }
- .container {
- width: min(95vw, 500px);
- padding: 15px;
- border-radius: 10px;
- background-color: var(--container-bg);
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
- transition: background-color 0.3s ease;
- }
- .header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 10px;
- }
- .title-container {
- display: flex;
- align-items: center;
- gap: 10px;
- }
- .title {
- color: var(--title-color);
- font-size: clamp(2rem, 8vw, 2.5rem);
- font-weight: bold;
- transition: color 0.3s ease;
- }
- .theme-toggle {
- background: none;
- border: none;
- cursor: pointer;
- font-size: 1.5rem;
- color: var(--text-color);
- transition: transform 0.3s ease;
- padding: 5px;
- margin-left: 10px;
- }
- .theme-toggle:hover {
- transform: rotate(30deg);
- }
- .scores {
- display: flex;
- gap: 10px;
- }
- .score-box {
- background-color: var(--button-bg);
- color: var(--button-text);
- padding: 8px 12px;
- border-radius: 5px;
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 70px;
- text-align: center;
- transition: background-color 0.3s ease;
- }
- .score-title {
- font-size: 0.7rem;
- text-transform: uppercase;
- margin-bottom: 2px;
- }
- .score-value {
- font-size: clamp(1.2rem, 5vw, 1.5rem);
- font-weight: bold;
- }
- .sub-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 10px;
- }
- .game-intro {
- color: var(--text-color);
- flex-basis: 60%;
- font-size: 0.9rem;
- line-height: 1.3;
- transition: color 0.3s ease;
- }
- .button-container {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- }
- .btn {
- background-color: var(--button-bg);
- color: var(--button-text);
- border: none;
- border-radius: 5px;
- padding: 10px 15px;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.2s ease, transform 0.1s ease;
- white-space: nowrap;
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 40px;
- }
- .btn:hover, .btn:focus {
- background-color: var(--button-hover-bg);
- outline: none;
- }
- .btn:active {
- transform: scale(0.95);
- }
- .btn:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .btn-icon {
- font-size: 1.2rem;
- line-height: 1;
- }
- .dropdown {
- position: relative;
- display: inline-block;
- }
- .dropdown-content {
- display: none;
- position: absolute;
- background-color: var(--bg-color);
- min-width: 160px;
- box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
- z-index: 1000;
- border-radius: 5px;
- right: 0;
- }
- .dropdown-content a {
- color: var(--text-color);
- padding: 12px 16px;
- text-decoration: none;
- display: block;
- transition: background-color 0.2s;
- }
- .dropdown-content a:hover {
- background-color: var(--container-bg);
- }
- .dropdown:hover .dropdown-content {
- display: block;
- }
- .game-container {
- position: relative;
- width: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- height: calc(var(--grid-size) * (var(--cell-size) + var(--cell-gap)) + var(--cell-gap));
- background-color: var(--container-bg);
- border-radius: var(--border-radius);
- padding: var(--cell-gap);
- margin: 0 auto;
- overflow: hidden;
- transition: width 0.3s ease, height 0.3s ease, background-color 0.3s ease;
- }
- .game-container.focused {
- outline: 3px solid #4c9aff;
- outline-offset: 3px;
- }
- .grid-background {
- position: absolute;
- top: var(--cell-gap);
- left: var(--cell-gap);
- bottom: var(--cell-gap);
- right: var(--cell-gap);
- display: grid;
- grid-template-columns: repeat(var(--grid-size), var(--cell-size));
- grid-template-rows: repeat(var(--grid-size), var(--cell-size));
- gap: var(--cell-gap);
- transition: grid-template-columns 0.3s ease, grid-template-rows 0.3s ease;
- }
- .cell {
- background-color: var(--cell-bg);
- border-radius: var(--border-radius);
- transition: background-color 0.3s ease;
- }
- .tile-container {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 5;
- }
- .tile {
- position: absolute;
- display: flex;
- justify-content: center;
- align-items: center;
- width: var(--cell-size);
- height: var(--cell-size);
- border-radius: var(--border-radius);
- font-size: clamp(1.5rem, 7vw, 2rem);
- font-weight: bold;
- background-color: var(--tile-2-bg);
- color: var(--tile-2-text);
- transition: top var(--animation-speed) ease-in-out,
- left var(--animation-speed) ease-in-out,
- width var(--animation-speed) ease-in-out,
- height var(--animation-speed) ease-in-out,
- background-color 0.3s ease,
- color 0.3s ease;
- z-index: 10;
- user-select: none;
- will-change: transform, opacity;
- }
- .tile.new-tile {
- animation: appear var(--appear-speed) ease-in-out;
- }
- @keyframes appear {
- 0% {
- opacity: 0;
- transform: scale(0);
- }
- 100% {
- opacity: 1;
- transform: scale(1);
- }
- }
- .tile.merged-tile {
- animation: pop var(--pop-speed) ease-in-out;
- z-index: 20;
- }
- @keyframes pop {
- 0% {
- transform: scale(1);
- }
- 50% {
- transform: scale(1.2);
- }
- 100% {
- transform: scale(1);
- }
- }
- .tile[data-value="2"] { background-color: var(--tile-2-bg); color: var(--tile-2-text); }
- .tile[data-value="4"] { background-color: var(--tile-4-bg); color: var(--tile-4-text); }
- .tile[data-value="8"] { background-color: var(--tile-8-bg); color: var(--tile-8-text); }
- .tile[data-value="16"] { background-color: var(--tile-16-bg); color: var(--tile-16-text); }
- .tile[data-value="32"] { background-color: var(--tile-32-bg); color: var(--tile-32-text); }
- .tile[data-value="64"] { background-color: var(--tile-64-bg); color: var(--tile-64-text); }
- .tile[data-value="128"] { background-color: var(--tile-128-bg); color: var(--tile-128-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="256"] { background-color: var(--tile-256-bg); color: var(--tile-256-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="512"] { background-color: var(--tile-512-bg); color: var(--tile-512-text); font-size: clamp(1.3rem, 6vw, 1.8rem); }
- .tile[data-value="1024"] { background-color: var(--tile-1024-bg); color: var(--tile-1024-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="2048"] { background-color: var(--tile-2048-bg); color: var(--tile-2048-text); font-size: clamp(1.1rem, 5vw, 1.5rem); }
- .tile[data-value="4096"],
- .tile[data-value="8192"],
- .tile[data-value="16384"],
- .tile[data-value="32768"],
- .tile[data-value="65536"] {
- background-color: var(--tile-super-bg);
- color: var(--tile-super-text);
- font-size: clamp(1rem, 4.5vw, 1.4rem);
- }
- .game-message {
- display: none;
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- background-color: var(--overlay-bg);
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- border-radius: var(--border-radius);
- z-index: 100;
- opacity: 0;
- transition: opacity 0.3s ease-in-out, background-color 0.3s ease;
- animation: fadein 0.3s ease-in-out forwards;
- }
- .game-message.active {
- display: flex;
- opacity: 1;
- }
- @keyframes fadein {
- from { opacity: 0; }
- to { opacity: 1; }
- }
- .game-message p {
- font-size: clamp(1.8rem, 7vw, 2rem);
- font-weight: bold;
- color: var(--title-color);
- margin-bottom: 20px;
- transition: color 0.3s ease;
- }
- .game-message .lower {
- display: flex;
- gap: 10px;
- margin-top: 10px;
- flex-wrap: wrap;
- justify-content: center;
- }
- .score-addition {
- position: fixed;
- animation: score-pop 0.8s ease-in-out forwards;
- font-weight: bold;
- color: var(--title-color);
- z-index: 100;
- pointer-events: none;
- font-size: 1.2rem;
- opacity: 0;
- transition: color 0.3s ease;
- }
- @keyframes score-pop {
- 0% { opacity: 1; transform: translate(-50%, 0); }
- 100% { opacity: 0; transform: translate(-50%, -30px); }
- }
- .stats-container {
- margin-top: 10px;
- background-color: var(--container-bg);
- border-radius: 8px;
- padding: 10px 15px;
- display: flex;
- flex-direction: column;
- gap: 5px;
- width: 100%;
- transition: background-color 0.3s ease;
- }
- .stats-title {
- font-weight: bold;
- color: var(--title-color);
- font-size: 1.1rem;
- margin-bottom: 5px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: color 0.3s ease;
- }
- .stats-row {
- display: flex;
- justify-content: space-between;
- font-size: 0.9rem;
- color: var(--text-color);
- transition: color 0.3s ease;
- }
- .stats-value {
- font-weight: bold;
- }
- .stats-toggle {
- font-size: 1rem;
- cursor: pointer;
- background: none;
- border: none;
- color: var(--text-color);
- transition: transform 0.3s ease, color 0.3s ease;
- }
- .stats-toggle:hover {
- transform: rotate(180deg);
- }
- .stats-content {
- overflow: hidden;
- max-height: 0;
- transition: max-height 0.3s ease-in-out;
- }
- .stats-content.expanded {
- max-height: 300px;
- }
- /* Modal styles */
- .modal {
- display: none;
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.5);
- z-index: 1000;
- justify-content: center;
- align-items: center;
- padding: 20px;
- }
- .modal.active {
- display: flex;
- }
- .modal-content {
- background-color: var(--modal-bg);
- border-radius: 10px;
- padding: 20px;
- max-width: 90%;
- width: 400px;
- max-height: 80vh;
- overflow-y: auto;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
- position: relative;
- color: var(--modal-text);
- border: 2px solid var(--modal-border);
- }
- .modal-title {
- font-size: 1.5rem;
- font-weight: bold;
- margin-bottom: 15px;
- color: var(--title-color);
- }
- .modal-close {
- position: absolute;
- top: 10px;
- right: 10px;
- background: none;
- border: none;
- font-size: 1.5rem;
- cursor: pointer;
- color: var(--text-color);
- transition: transform 0.3s ease;
- }
- .modal-close:hover {
- transform: rotate(90deg);
- }
- .settings-group {
- margin-bottom: 15px;
- }
- .settings-label {
- display: block;
- margin-bottom: 5px;
- font-weight: bold;
- }
- .settings-select {
- width: 100%;
- padding: 8px;
- border-radius: 5px;
- border: 1px solid var(--container-bg);
- background-color: var(--bg-color);
- color: var(--text-color);
- margin-bottom: 10px;
- }
- /* Game Size Controls */
- .size-controls {
- display: flex;
- justify-content: space-between;
- margin-top: 10px;
- margin-bottom: 10px;
- }
- .size-btn {
- background-color: var(--button-bg);
- color: var(--button-text);
- border: none;
- border-radius: 5px;
- padding: 8px 12px;
- cursor: pointer;
- transition: background-color 0.2s;
- }
- .size-btn:hover {
- background-color: var(--button-hover-bg);
- }
- .size-btn.active {
- background-color: var(--title-color);
- }
- /* Saved Games List */
- .saved-games-list {
- max-height: 300px;
- overflow-y: auto;
- margin-top: 10px;
- }
- .saved-game-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px;
- border-bottom: 1px solid var(--container-bg);
- transition: background-color 0.2s;
- }
- .saved-game-item:hover {
- background-color: var(--container-bg);
- }
- .saved-game-info {
- flex-grow: 1;
- }
- .saved-game-title {
- font-weight: bold;
- margin-bottom: 5px;
- }
- .saved-game-meta {
- font-size: 0.8rem;
- color: var(--text-color);
- opacity: 0.8;
- }
- .saved-game-actions {
- display: flex;
- gap: 5px;
- }
- /* Achievement notification */
- .achievement {
- position: fixed;
- top: 20px;
- right: 20px;
- background-color: var(--achievement-bg);
- color: var(--achievement-text);
- padding: 15px;
- border-radius: 5px;
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
- z-index: 1000;
- transition: transform 0.3s ease, opacity 0.3s ease;
- transform: translateX(120%);
- opacity: 0;
- max-width: 300px;
- }
- .achievement.active {
- transform: translateX(0);
- opacity: 1;
- }
- .achievement-title {
- font-weight: bold;
- margin-bottom: 5px;
- font-size: 1.1rem;
- }
- .achievement-description {
- font-size: 0.9rem;
- }
- /* Achievements list */
- .achievements-list {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
- gap: 10px;
- margin-top: 10px;
- }
- .achievement-item {
- background-color: var(--container-bg);
- border-radius: 5px;
- padding: 10px;
- opacity: 0.6;
- transition: opacity 0.3s;
- }
- .achievement-item.unlocked {
- opacity: 1;
- }
- .achievement-item-title {
- font-weight: bold;
- margin-bottom: 5px;
- }
- .achievement-item-description {
- font-size: 0.9rem;
- }
- /* Game history controls */
- .history-controls {
- display: flex;
- justify-content: center;
- gap: 10px;
- margin-top: 10px;
- }
- /* Responsive adjustments */
- @media screen and (max-width: 520px) {
- :root {
- --cell-size: 65px;
- --cell-gap: 8px;
- }
- .game-intro { flex-basis: 100%; text-align: center; margin-bottom: 10px; }
- .btn { padding: 8px 12px; }
- .button-container { justify-content: center; width: 100%; }
- }
- @media screen and (max-width: 420px) {
- :root {
- --cell-size: 60px;
- --cell-gap: 7px;
- }
- }
- @media screen and (max-width: 380px) {
- :root {
- --cell-size: 55px;
- --cell-gap: 6px;
- }
- .container { padding: 10px; }
- .header { justify-content: center; }
- .title { text-align: center; width: 100%; margin-bottom: 5px;}
- .sub-header { justify-content: center; }
- .score-box { min-width: 60px; padding: 6px 10px; }
- .button-container { flex-wrap: wrap; }
- }
- @media screen and (max-width: 340px) {
- :root {
- --cell-size: 50px;
- --cell-gap: 5px;
- }
- }
- .footer {
- margin-top: 20px;
- text-align: center;
- color: var(--text-color);
- font-size: 0.8rem;
- transition: color 0.3s ease;
- display: flex;
- flex-direction: column;
- gap: 5px;
- align-items: center;
- }
- .controls-row {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- justify-content: center;
- }
- .animation-toggle {
- border: none;
- background: none;
- cursor: pointer;
- font-size: 1.2rem;
- color: var(--text-color);
- transition: color 0.3s ease;
- display: flex;
- align-items: center;
- gap: 5px;
- }
- .animation-toggle:hover {
- color: var(--title-color);
- }
- .animation-status {
- font-size: 0.8rem;
- }
- .keyboard-shortcuts {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background-color: var(--overlay-bg);
- z-index: 1000;
- display: none;
- justify-content: center;
- align-items: center;
- transition: background-color 0.3s ease;
- }
- .keyboard-shortcuts.active {
- display: flex;
- }
- .shortcuts-content {
- background-color: var(--bg-color);
- border-radius: 10px;
- padding: 20px;
- max-width: 90%;
- width: 400px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
- position: relative;
- transition: background-color 0.3s ease;
- }
- .shortcuts-title {
- font-size: 1.5rem;
- font-weight: bold;
- margin-bottom: 15px;
- color: var(--title-color);
- transition: color 0.3s ease;
- }
- .shortcut-grid {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: 10px 20px;
- margin-bottom: 20px;
- color: var(--text-color);
- }
- .key {
- background-color: var(--container-bg);
- color: var(--text-color);
- padding: 5px 10px;
- border-radius: 5px;
- font-family: monospace;
- text-align: center;
- font-weight: bold;
- transition: background-color 0.3s ease, color 0.3s ease;
- }
- .close-shortcuts {
- position: absolute;
- top: 10px;
- right: 10px;
- background: none;
- border: none;
- font-size: 1.5rem;
- cursor: pointer;
- color: var(--text-color);
- transition: transform 0.3s ease, color 0.3s ease;
- }
- .close-shortcuts:hover {
- transform: rotate(90deg);
- }
- .toast {
- position: fixed;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
- background-color: var(--container-bg);
- color: var(--text-color);
- padding: 10px 20px;
- border-radius: 5px;
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
- z-index: 1000;
- transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease, color 0.3s ease;
- opacity: 0;
- transform: translate(-50%, 20px);
- pointer-events: none;
- }
- .toast.active {
- opacity: 1;
- transform: translate(-50%, 0);
- }
- button:focus-visible, a:focus-visible, [tabindex="0"]:focus-visible {
- outline: 3px solid #4c9aff;
- outline-offset: 2px;
- }
- .skip-link {
- position: absolute;
- top: -40px;
- left: 0;
- background: var(--button-bg);
- color: var(--button-text);
- padding: 8px;
- z-index: 100;
- transition: top 0.3s ease;
- }
- .skip-link:focus {
- top: 0;
- }
- </style>
- </head>
- <body>
- <a href="#game-container" class="skip-link">Skip to game</a>
- <div class="container">
- <header class="header">
- <div class="title-container">
- <h1 class="title">2048</h1>
- <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode">🌓</button>
- </div>
- <div class="scores">
- <div class="score-box">
- <div class="score-title">Score</div>
- <div class="score-value" id="score" aria-live="polite">0</div>
- </div>
- <div class="score-box">
- <div class="score-title">Best</div>
- <div class="score-value" id="best-score">0</div>
- </div>
- </div>
- </header>
- <div class="sub-header">
- <p class="game-intro">Join tiles, get to <strong>2048!</strong> Use <strong>arrow keys</strong> or <strong>swipe</strong>.</p>
- <div class="button-container">
- <button class="btn" id="restart-button" aria-label="New Game">New</button>
- <button class="btn" id="undo-button" aria-label="Undo Move" disabled>↩️</button>
- <div class="dropdown">
- <button class="btn" id="menu-button" aria-haspopup="true" aria-expanded="false">☰</button>
- <div class="dropdown-content" id="menu-dropdown">
- <a href="#" id="settings-link">Settings</a>
- <a href="#" id="save-link">Save Game</a>
- <a href="#" id="load-link">Load Game</a>
- <a href="#" id="achievements-link">Achievements</a>
- <a href="#" id="help-link">Help</a>
- </div>
- </div>
- </div>
- </div>
- <div class="game-container" id="game-container" tabindex="0" aria-label="Game Board">
- <div class="grid-background" id="grid-background"></div>
- <div class="tile-container" id="tile-container"></div>
- <div class="game-message" id="game-message" aria-live="assertive">
- <p id="message-text"></p>
- <div class="lower">
- <button class="btn" id="retry-button">Try again</button>
- <button class="btn" id="keep-playing-button" style="display: none;">Keep Playing</button>
- <button class="btn" id="save-score-button" style="display: none;">Save Score</button>
- </div>
- </div>
- </div>
- <div class="stats-container" id="stats-container">
- <div class="stats-title">
- Game Statistics
- <button class="stats-toggle" id="stats-toggle" aria-label="Toggle statistics visibility" aria-expanded="false">▼</button>
- </div>
- <div class="stats-content" id="stats-content">
- <div class="stats-row"><span>Games Played:</span><span class="stats-value" id="games-played">0</span></div>
- <div class="stats-row"><span>Highest Tile:</span><span class="stats-value" id="highest-tile">0</span></div>
- <div class="stats-row"><span>Total Moves Made:</span><span class="stats-value" id="total-moves">0</span></div>
- <div class="stats-row"><span>Total Score Accumulated:</span><span class="stats-value" id="total-score">0</span></div>
- <div class="stats-row"><span>Avg Score/Game:</span><span class="stats-value" id="avg-score">0</span></div>
- <div class="stats-row"><span>Current Difficulty:</span><span class="stats-value" id="current-difficulty">Normal</span></div>
- <div class="stats-row"><span>Current Grid Size:</span><span class="stats-value" id="current-grid-size">4×4</span></div>
- <div class="stats-row"><span>Achievements Unlocked:</span><span class="stats-value" id="achievements-unlocked">0/10</span></div>
- </div>
- </div>
- <footer class="footer">
- <div class="controls-row">
- <button class="animation-toggle" id="animation-toggle" aria-pressed="true">
- <span class="btn-icon">✨</span>
- <span class="animation-status" id="animation-status">Animations: On</span>
- </button>
- </div>
- <p>Ultimate Edition • <a href="#" id="keyboard-link">Keyboard shortcuts</a></p>
- </footer>
- </div>
- <!-- Keyboard Shortcuts Modal -->
- <div class="keyboard-shortcuts" id="keyboard-shortcuts" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title">
- <div class="shortcuts-content">
- <button class="close-shortcuts" id="close-shortcuts" aria-label="Close keyboard shortcuts">×</button>
- <h2 class="shortcuts-title" id="shortcuts-title">Keyboard Shortcuts</h2>
- <div class="shortcut-grid">
- <div class="key">↑ ↓ ← →</div><div>Move Tiles</div>
- <div class="key">W A S D</div><div>Move Tiles</div>
- <div class="key">R</div><div>Restart Game</div>
- <div class="key">U / Z</div><div>Undo Move</div>
- <div class="key">M</div><div>Toggle Dark Mode</div>
- <div class="key">T</div><div>Toggle Animations</div>
- <div class="key">S</div><div>Save Game</div>
- <div class="key">L</div><div>Load Game</div>
- <div class="key">O</div><div>Open Settings</div>
- <div class="key">Esc</div><div>Close Dialogs</div>
- <div class="key">?</div><div>Show This Help</div>
- </div>
- </div>
- </div>
- <!-- Settings Modal -->
- <div class="modal" id="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
- <div class="modal-content">
- <button class="modal-close" id="settings-close" aria-label="Close settings">×</button>
- <h2 class="modal-title" id="settings-title">Game Settings</h2>
- <div class="settings-group">
- <label class="settings-label" for="difficulty-select">Difficulty</label>
- <select class="settings-select" id="difficulty-select">
- <option value="easy">Easy (10% chance of 4s)</option>
- <option value="normal" selected>Normal (20% chance of 4s)</option>
- <option value="hard">Hard (30% chance of 4s)</option>
- <option value="expert">Expert (40% chance of 4s)</option>
- </select>
- </div>
- <div class="settings-group">
- <label class="settings-label">Grid Size</label>
- <div class="size-controls">
- <button class="size-btn" data-size="3">3×3</button>
- <button class="size-btn active" data-size="4">4×4</button>
- <button class="size-btn" data-size="5">5×5</button>
- <button class="size-btn" data-size="6">6×6</button>
- </div>
- <p><small>Note: Changing grid size will restart the game.</small></p>
- </div>
- <div class="settings-group">
- <button class="btn" id="apply-settings">Apply Settings</button>
- </div>
- </div>
- </div>
- <!-- Save Game Modal -->
- <div class="modal" id="save-modal" role="dialog" aria-modal="true" aria-labelledby="save-title">
- <div class="modal-content">
- <button class="modal-close" id="save-close" aria-label="Close save dialog">×</button>
- <h2 class="modal-title" id="save-title">Save Game</h2>
- <div class="settings-group">
- <label class="settings-label" for="save-name">Save Name</label>
- <input type="text" id="save-name" class="settings-select" placeholder="My Game">
- </div>
- <div class="settings-group">
- <button class="btn" id="save-game-btn">Save Game</button>
- </div>
- </div>
- </div>
- <!-- Load Game Modal -->
- <div class="modal" id="load-modal" role="dialog" aria-modal="true" aria-labelledby="load-title">
- <div class="modal-content">
- <button class="modal-close" id="load-close" aria-label="Close load dialog">×</button>
- <h2 class="modal-title" id="load-title">Load Game</h2>
- <div id="saved-games-list" class="saved-games-list">
- <!-- Saved games will be listed here -->
- <p id="no-saves-message">No saved games found.</p>
- </div>
- </div>
- </div>
- <!-- Achievements Modal -->
- <div class="modal" id="achievements-modal" role="dialog" aria-modal="true" aria-labelledby="achievements-title">
- <div class="modal-content">
- <button class="modal-close" id="achievements-close" aria-label="Close achievements">×</button>
- <h2 class="modal-title" id="achievements-title">Achievements</h2>
- <div id="achievements-list" class="achievements-list">
- <!-- Achievements will be listed here -->
- </div>
- </div>
- </div>
- <!-- Achievement Notification -->
- <div class="achievement" id="achievement-notification">
- <div class="achievement-title">Achievement Unlocked!</div>
- <div class="achievement-description" id="achievement-description"></div>
- </div>
- <!-- Toast Notification -->
- <div class="toast" id="toast" aria-live="assertive"></div>
- <script>
- document.addEventListener('DOMContentLoaded', () => {
- // --- Constants ---
- const CONSTANTS = {
- DEFAULT_GRID_SIZE: 4,
- WINNING_VALUE: 2048,
- ANIMATION_DURATIONS: {
- MOVE: 150, // ms
- APPEAR: 200, // ms
- POP: 200 // ms
- },
- MAX_UNDO_STEPS: 30,
- SWIPE_THRESHOLD: 30,
- STORAGE_KEYS: {
- BEST_SCORE: 'bestScore2048Ultimate',
- GAME_STATE: 'savedGame2048Ultimate',
- STATS: 'gameStats2048Ultimate',
- SETTINGS: 'settings2048Ultimate',
- ACHIEVEMENTS: 'achievements2048Ultimate',
- SAVED_GAMES: 'savedGames2048Ultimate'
- },
- DIFFICULTY_LEVELS: {
- easy: { name: 'Easy', fourProbability: 0.1 },
- normal: { name: 'Normal', fourProbability: 0.2 },
- hard: { name: 'Hard', fourProbability: 0.3 },
- expert: { name: 'Expert', fourProbability: 0.4 }
- },
- GRID_SIZES: [3, 4, 5, 6],
- CELL_SIZES: {
- 3: { cell: '120px', gap: '15px' },
- 4: { cell: '100px', gap: '12px' },
- 5: { cell: '80px', gap: '10px' },
- 6: { cell: '65px', gap: '8px' }
- }
- };
- // --- Event Manager ---
- class EventManager {
- constructor() {
- this.events = {};
- }
- on(event, callback) {
- if (!this.events[event]) {
- this.events[event] = [];
- }
- this.events[event].push(callback);
- return this; // For chaining
- }
- off(event, callback) {
- if (!this.events[event]) return this;
- if (callback) {
- this.events[event] = this.events[event].filter(cb => cb !== callback);
- } else {
- delete this.events[event];
- }
- return this;
- }
- emit(event, ...args) {
- if (!this.events[event]) return;
- this.events[event].forEach(callback => {
- callback(...args);
- });
- return this;
- }
- }
- // --- UI Manager ---
- class UIManager {
- constructor(elements, eventManager) {
- this.elements = elements;
- this.events = eventManager;
- this.activeModals = [];
- this.toastTimeout = null;
- this.achievementTimeout = null;
- // Initialize UI event listeners
- this.setupUIEvents();
- }
- setupUIEvents() {
- // Button event listeners
- this.elements.restartButton.addEventListener('click', () => this.events.emit('restart'));
- this.elements.retryButton.addEventListener('click', () => this.events.emit('restart'));
- this.elements.undoButton.addEventListener('click', () => this.events.emit('undo'));
- this.elements.keepPlayingButton.addEventListener('click', () => this.events.emit('keepPlaying'));
- this.elements.themeToggle.addEventListener('click', () => this.events.emit('toggleTheme'));
- this.elements.animationToggle.addEventListener('click', () => this.events.emit('toggleAnimations'));
- this.elements.statsToggle.addEventListener('click', () => this.toggleStats());
- // Menu items
- this.elements.settingsLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showModal('settings');
- });
- this.elements.saveLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showModal('save');
- });
- this.elements.loadLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showModal('load');
- });
- this.elements.achievementsLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showModal('achievements');
- });
- this.elements.helpLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showKeyboardShortcuts();
- });
- // Modal close buttons
- document.querySelectorAll('.modal-close').forEach(button => {
- button.addEventListener('click', () => {
- this.closeAllModals();
- });
- });
- // Settings modal
- this.elements.applySettings.addEventListener('click', () => {
- const difficulty = this.elements.difficultySelect.value;
- const gridSizeBtn = document.querySelector('.size-btn.active');
- const gridSize = gridSizeBtn ? parseInt(gridSizeBtn.dataset.size) : CONSTANTS.DEFAULT_GRID_SIZE;
- this.events.emit('applySettings', { difficulty, gridSize });
- this.closeAllModals();
- });
- // Grid size buttons
- document.querySelectorAll('.size-btn').forEach(button => {
- button.addEventListener('click', () => {
- document.querySelectorAll('.size-btn').forEach(btn => btn.classList.remove('active'));
- button.classList.add('active');
- });
- });
- // Save game button
- this.elements.saveGameBtn.addEventListener('click', () => {
- const saveName = this.elements.saveName.value.trim() || `Game ${new Date().toLocaleString()}`;
- this.events.emit('saveGame', saveName);
- this.closeAllModals();
- });
- // Keyboard shortcuts
- this.elements.keyboardLink.addEventListener('click', (e) => {
- e.preventDefault();
- this.showKeyboardShortcuts();
- });
- this.elements.closeShortcuts.addEventListener('click', () => {
- this.hideKeyboardShortcuts();
- });
- }
- updateScore(score, bestScore, addition = 0) {
- this.elements.scoreElement.textContent = score;
- this.elements.bestScoreElement.textContent = bestScore;
- if (addition > 0) {
- this.showScoreAddition(addition);
- }
- }
- updateUndoButton(enabled) {
- this.elements.undoButton.disabled = !enabled;
- }
- updateStatsDisplay(stats, settings) {
- this.elements.gamesPlayedElement.textContent = stats.gamesPlayed;
- this.elements.highestTileElement.textContent = stats.highestTile;
- this.elements.totalMovesElement.textContent = stats.totalMoves;
- this.elements.totalScoreElement.textContent = stats.totalScore;
- const avgScore = stats.gamesPlayed > 0
- ? Math.round(stats.totalScore / stats.gamesPlayed)
- : 0;
- this.elements.avgScoreElement.textContent = avgScore;
- this.elements.currentDifficulty.textContent =
- CONSTANTS.DIFFICULTY_LEVELS[settings.difficulty].name;
- this.elements.currentGridSize.textContent = `${settings.gridSize}×${settings.gridSize}`;
- // Update achievements count
- const achievementsCount = this.countUnlockedAchievements();
- this.elements.achievementsUnlocked.textContent = `${achievementsCount}/10`;
- }
- countUnlockedAchievements() {
- try {
- const achievements = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.ACHIEVEMENTS) || '{}');
- return Object.values(achievements).filter(a => a.unlocked).length;
- } catch (e) {
- return 0;
- }
- }
- showScoreAddition(addition) {
- const scoreAdditionElement = document.createElement('div');
- scoreAdditionElement.classList.add('score-addition');
- scoreAdditionElement.textContent = `+${addition}`;
- const scoreRect = this.elements.scoreElement.getBoundingClientRect();
- scoreAdditionElement.style.position = 'fixed';
- scoreAdditionElement.style.top = `${scoreRect.top - 10}px`;
- scoreAdditionElement.style.left = `${scoreRect.left + scoreRect.width / 2}px`;
- document.body.appendChild(scoreAdditionElement);
- scoreAdditionElement.addEventListener('animationend', () => {
- if (scoreAdditionElement.parentNode) {
- document.body.removeChild(scoreAdditionElement);
- }
- }, { once: true });
- }
- showMessage(text, type) {
- this.elements.messageText.textContent = text;
- this.elements.messageContainer.className = 'game-message active';
- this.elements.keepPlayingButton.style.display = 'none';
- this.elements.saveScoreButton.style.display = 'none';
- if (type === 'win') {
- this.elements.messageContainer.classList.add('game-won');
- this.elements.keepPlayingButton.style.display = 'inline-block';
- this.elements.saveScoreButton.style.display = 'inline-block';
- } else if (type === 'over') {
- this.elements.messageContainer.classList.add('game-over');
- this.elements.saveScoreButton.style.display = 'inline-block';
- }
- }
- hideMessage() {
- this.elements.messageContainer.classList.remove('active', 'game-won', 'game-over');
- }
- showToast(message, duration = 2000) {
- const toastElement = this.elements.toast;
- toastElement.textContent = message;
- toastElement.classList.add('active');
- if (this.toastTimeout) clearTimeout(this.toastTimeout);
- this.toastTimeout = setTimeout(() => {
- toastElement.classList.remove('active');
- }, duration);
- }
- showAchievement(achievement) {
- const notification = this.elements.achievementNotification;
- const description = this.elements.achievementDescription;
- description.textContent = achievement.description;
- notification.classList.add('active');
- if (this.achievementTimeout) clearTimeout(this.achievementTimeout);
- this.achievementTimeout = setTimeout(() => {
- notification.classList.remove('active');
- }, 3000);
- }
- updateAnimationStatus(enabled) {
- this.elements.animationStatus.textContent = `Animations: ${enabled ? 'On' : 'Off'}`;
- this.elements.animationToggle.setAttribute('aria-pressed', enabled);
- }
- updateTheme(darkMode) {
- document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
- }
- toggleStats() {
- const isExpanded = this.elements.statsContent.classList.toggle('expanded');
- this.elements.statsToggle.textContent = isExpanded ? '▲' : '▼';
- this.elements.statsToggle.setAttribute('aria-expanded', isExpanded);
- }
- showKeyboardShortcuts() {
- this.elements.keyboardShortcuts.classList.add('active');
- this.elements.closeShortcuts.focus();
- }
- hideKeyboardShortcuts() {
- this.elements.keyboardShortcuts.classList.remove('active');
- this.elements.helpLink.focus();
- }
- showModal(modalType) {
- const modalId = `${modalType}-modal`;
- const modal = document.getElementById(modalId);
- if (!modal) return;
- // Close any open modals first
- this.closeAllModals();
- modal.classList.add('active');
- this.activeModals.push(modalId);
- // Focus the first focusable element
- const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
- if (focusable) focusable.focus();
- // If it's the load modal, refresh the saved games list
- if (modalType === 'load') {
- this.refreshSavedGamesList();
- } else if (modalType === 'achievements') {
- this.refreshAchievementsList();
- }
- }
- closeAllModals() {
- this.activeModals.forEach(modalId => {
- const modal = document.getElementById(modalId);
- if (modal) modal.classList.remove('active');
- });
- this.activeModals = [];
- this.elements.gameContainer.focus();
- }
- refreshSavedGamesList() {
- const listElement = document.getElementById('saved-games-list');
- const noSavesMessage = document.getElementById('no-saves-message');
- try {
- const savedGames = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES) || '[]');
- if (savedGames.length === 0) {
- noSavesMessage.style.display = 'block';
- return;
- }
- noSavesMessage.style.display = 'none';
- // Clear previous list
- listElement.innerHTML = '';
- // Add each saved game to the list
- savedGames.forEach((save, index) => {
- const item = document.createElement('div');
- item.className = 'saved-game-item';
- item.innerHTML = `
- <div class="saved-game-info">
- <div class="saved-game-title">${save.name}</div>
- <div class="saved-game-meta">
- Score: ${save.score} | Grid: ${save.gridSize}×${save.gridSize} |
- ${new Date(save.timestamp).toLocaleString()}
- </div>
- </div>
- <div class="saved-game-actions">
- <button class="btn load-game-btn" data-index="${index}">Load</button>
- <button class="btn delete-game-btn" data-index="${index}">Delete</button>
- </div>
- `;
- listElement.appendChild(item);
- });
- // Add event listeners to the buttons
- document.querySelectorAll('.load-game-btn').forEach(button => {
- button.addEventListener('click', () => {
- const index = parseInt(button.dataset.index);
- this.events.emit('loadSavedGame', index);
- this.closeAllModals();
- });
- });
- document.querySelectorAll('.delete-game-btn').forEach(button => {
- button.addEventListener('click', () => {
- const index = parseInt(button.dataset.index);
- this.events.emit('deleteSavedGame', index);
- this.refreshSavedGamesList();
- });
- });
- } catch (e) {
- console.error('Error loading saved games:', e);
- noSavesMessage.style.display = 'block';
- }
- }
- refreshAchievementsList() {
- const listElement = document.getElementById('achievements-list');
- try {
- const achievements = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.ACHIEVEMENTS) || '{}');
- const achievementsList = [
- { id: 'first_win', title: 'First Victory', description: 'Reach the 2048 tile for the first time' },
- { id: 'super_tile', title: 'Super Tile', description: 'Create a tile with value 4096 or higher' },
- { id: 'persistent', title: 'Persistent', description: 'Play 10 games' },
- { id: 'master', title: 'Master', description: 'Play 50 games' },
- { id: 'high_score', title: 'High Scorer', description: 'Reach a score of 20,000 or more' },
- { id: 'efficient', title: 'Efficient', description: 'Win a game with fewer than 300 moves' },
- { id: 'comeback', title: 'Comeback Kid', description: 'Undo a move and still win the game' },
- { id: 'explorer', title: 'Explorer', description: 'Play on all grid sizes' },
- { id: 'challenge_seeker', title: 'Challenge Seeker', description: 'Win on Hard or Expert difficulty' },
- { id: 'completionist', title: 'Completionist', description: 'Unlock all other achievements' }
- ];
- // Clear previous list
- listElement.innerHTML = '';
- // Add each achievement to the list
- achievementsList.forEach(achievement => {
- const isUnlocked = achievements[achievement.id]?.unlocked || false;
- const item = document.createElement('div');
- item.className = `achievement-item ${isUnlocked ? 'unlocked' : ''}`;
- item.innerHTML = `
- <div class="achievement-item-title">${isUnlocked ? '🏆' : '🔒'} ${achievement.title}</div>
- <div class="achievement-item-description">${achievement.description}</div>
- `;
- listElement.appendChild(item);
- });
- } catch (e) {
- console.error('Error loading achievements:', e);
- listElement.innerHTML = '<p>Error loading achievements.</p>';
- }
- }
- updateGridSize(size) {
- // Update CSS variables for grid size
- const cellSize = CONSTANTS.CELL_SIZES[size].cell;
- const cellGap = CONSTANTS.CELL_SIZES[size].gap;
- document.documentElement.style.setProperty('--grid-size', size);
- document.documentElement.style.setProperty('--cell-size', cellSize);
- document.documentElement.style.setProperty('--cell-gap', cellGap);
- // Resize the grid background with proper number of cells
- this.elements.gridBackground.innerHTML = '';
- for (let i = 0; i < size * size; i++) {
- const cell = document.createElement('div');
- cell.classList.add('cell');
- this.elements.gridBackground.appendChild(cell);
- }
- }
- }
- // --- Grid Manager ---
- class GridManager {
- constructor(gridSize, tileContainer, settings) {
- this.gridSize = gridSize;
- this.tileContainer = tileContainer;
- this.settings = settings;
- this.grid = [];
- this.initializeGrid();
- }
- initializeGrid() {
- this.grid = Array(this.gridSize).fill(null).map(() => Array(this.gridSize).fill(null));
- }
- setGridSize(newSize) {
- this.gridSize = newSize;
- this.initializeGrid();
- }
- clearGrid() {
- this.tileContainer.innerHTML = '';
- this.initializeGrid();
- }
- getEmptyCells() {
- const emptyCells = [];
- for (let r = 0; r < this.gridSize; r++) {
- for (let c = 0; c < this.gridSize; c++) {
- if (this.grid[r][c] === null) {
- emptyCells.push({ r, c });
- }
- }
- }
- return emptyCells;
- }
- addRandomTile() {
- const emptyCells = this.getEmptyCells();
- if (emptyCells.length === 0) return null;
- const { r, c } = emptyCells[Math.floor(Math.random() * emptyCells.length)];
- // Determine value based on difficulty
- const fourProbability = CONSTANTS.DIFFICULTY_LEVELS[this.settings.difficulty].fourProbability;
- const value = Math.random() < fourProbability ? 4 : 2;
- const newTile = new Tile(value, r, c, this.tileContainer, this.settings);
- this.grid[r][c] = newTile;
- return newTile;
- }
- canMove() {
- if (this.getEmptyCells().length > 0) return true;
- for (let r = 0; r < this.gridSize; r++) {
- for (let c = 0; c < this.gridSize; c++) {
- const tile = this.grid[r][c];
- if (!tile) continue;
- // Check right and down - sufficient to check all adjacencies
- if (c < this.gridSize - 1 && this.grid[r][c + 1]?.value === tile.value) return true;
- if (r < this.gridSize - 1 && this.grid[r + 1][c]?.value === tile.value) return true;
- }
- }
- return false;
- }
- getTraversals(direction) {
- const rows = Array.from({ length: this.gridSize }, (_, i) => i);
- const cols = Array.from({ length: this.gridSize }, (_, i) => i);
- if (direction === 'right') cols.reverse();
- if (direction === 'down') rows.reverse();
- return { rows, cols };
- }
- findFurthestPosition(r, c, direction) {
- let currentRow = r, currentCol = c, nextRow, nextCol;
- do {
- currentRow = nextRow !== undefined ? nextRow : r;
- currentCol = nextCol !== undefined ? nextCol : c;
- const { nr, nc } = this.getNextPosition(currentRow, currentCol, direction);
- nextRow = nr;
- nextCol = nc;
- } while (
- this.isWithinBounds(nextRow, nextCol) &&
- this.grid[nextRow][nextCol] === null
- );
- const furthestRow = currentRow;
- const furthestCol = currentCol;
- const nextTile = this.isWithinBounds(nextRow, nextCol) ? this.grid[nextRow][nextCol] : null;
- return { furthestRow, furthestCol, nextTile };
- }
- getNextPosition(r, c, direction) {
- switch (direction) {
- case 'up': return { nr: r - 1, nc: c };
- case 'down': return { nr: r + 1, nc: c };
- case 'left': return { nr: r, nc: c - 1 };
- case 'right': return { nr: r, nc: c + 1 };
- default: return { nr: r, nc: c };
- }
- }
- isWithinBounds(r, c) {
- return r >= 0 && r < this.gridSize && c >= 0 && c < this.gridSize;
- }
- resetMergeStates() {
- for (let r = 0; r < this.gridSize; r++) {
- for (let c = 0; c < this.gridSize; c++) {
- if (this.grid[r][c]) {
- this.grid[r][c].mergedFrom = null;
- }
- }
- }
- }
- // Create a deep copy of the grid for history
- cloneGrid() {
- const gridCopy = Array(this.gridSize).fill(null).map(() => Array(this.gridSize).fill(null));
- for (let r = 0; r < this.gridSize; r++) {
- for (let c = 0; c < this.gridSize; c++) {
- if (this.grid[r][c]) {
- gridCopy[r][c] = this.grid[r][c].clone();
- }
- }
- }
- return gridCopy;
- }
- // Restore grid from a saved state
- restoreGrid(savedGrid) {
- this.clearGrid();
- for (let r = 0; r < this.gridSize; r++) {
- for (let c = 0; c < this.gridSize; c++) {
- if (savedGrid[r] && savedGrid[r][c]) {
- const { value, row, col } = savedGrid[r][c];
- this.grid[r][c] = new Tile(value, row, col, this.tileContainer, this.settings);
- }
- }
- }
- }
- // Process a move in the given direction
- async move(direction) {
- this.resetMergeStates();
- let moved = false;
- let scoreAddition = 0;
- const mergedTiles = [];
- const traversals = this.getTraversals(direction);
- traversals.rows.forEach(r => {
- traversals.cols.forEach(c => {
- const currentTile = this.grid[r][c];
- if (!currentTile) return;
- const { furthestRow, furthestCol, nextTile } = this.findFurthestPosition(r, c, direction);
- if (nextTile && nextTile.value === currentTile.value && !nextTile.mergedFrom) {
- // --- Merge ---
- const mergedValue = currentTile.value * 2;
- nextTile.updateValue(mergedValue);
- nextTile.mergedFrom = currentTile;
- this.grid[r][c] = null;
- currentTile.row = nextTile.row;
- currentTile.col = nextTile.col;
- currentTile.updatePosition();
- // Track for animation synchronization
- mergedTiles.push({ tile: nextTile, originalTile: currentTile });
- scoreAddition += mergedValue;
- moved = true;
- } else if (furthestRow !== r || furthestCol !== c) {
- // --- Move ---
- this.grid[furthestRow][furthestCol] = currentTile;
- this.grid[r][c] = null;
- currentTile.row = furthestRow;
- currentTile.col = furthestCol;
- currentTile.updatePosition();
- moved = true;
- }
- });
- });
- // Return a promise that resolves when animations complete
- return new Promise(resolve => {
- if (!moved) {
- resolve({ moved: false, scoreAddition: 0 });
- return;
- }
- if (this.settings.animationsEnabled) {
- // Play merge animations
- mergedTiles.forEach(({ tile, originalTile }) => {
- tile.playMergeAnimation();
- originalTile.remove(true);
- });
- // Wait for animations to complete
- setTimeout(() => {
- resolve({ moved, scoreAddition });
- }, CONSTANTS.ANIMATION_DURATIONS.MOVE + 50);
- } else {
- // If animations are disabled, remove merged tiles immediately
- mergedTiles.forEach(({ originalTile }) => {
- originalTile.remove(false);
- });
- resolve({ moved, scoreAddition });
- }
- });
- }
- }
- // --- Achievement Manager ---
- class AchievementManager {
- constructor(eventManager) {
- this.events = eventManager;
- this.achievements = this.loadAchievements();
- // Setup achievement checks
- this.setupAchievementListeners();
- }
- loadAchievements() {
- try {
- return JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.ACHIEVEMENTS) || '{}');
- } catch (e) {
- console.error('Error loading achievements:', e);
- return {};
- }
- }
- saveAchievements() {
- try {
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.ACHIEVEMENTS, JSON.stringify(this.achievements));
- } catch (e) {
- console.error('Error saving achievements:', e);
- }
- }
- setupAchievementListeners() {
- // Listen for game events to check for achievements
- this.events.on('gameWon', (data) => {
- this.checkFirstWin();
- this.checkEfficientWin(data.moves);
- this.checkDifficultyWin(data.difficulty);
- if (data.undoUsed) this.checkComebackWin();
- });
- this.events.on('gameOver', () => {
- this.checkPersistent();
- this.checkMaster();
- });
- this.events.on('newHighScore', (score) => {
- this.checkHighScore(score);
- });
- this.events.on('newHighTile', (value) => {
- this.checkSuperTile(value);
- });
- this.events.on('gridSizeChanged', () => {
- this.checkExplorer();
- });
- // Check completionist periodically
- this.events.on('achievementUnlocked', () => {
- this.checkCompletionist();
- });
- }
- unlockAchievement(id, title, description) {
- if (this.achievements[id]?.unlocked) return false; // Already unlocked
- this.achievements[id] = {
- unlocked: true,
- timestamp: Date.now()
- };
- this.saveAchievements();
- // Emit event for UI notification
- this.events.emit('achievementUnlocked', { id, title, description });
- return true;
- }
- checkFirstWin() {
- this.unlockAchievement(
- 'first_win',
- 'First Victory',
- 'Reach the 2048 tile for the first time'
- );
- }
- checkSuperTile(value) {
- if (value >= 4096) {
- this.unlockAchievement(
- 'super_tile',
- 'Super Tile',
- 'Create a tile with value 4096 or higher'
- );
- }
- }
- checkPersistent() {
- try {
- const stats = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.STATS) || '{}');
- if (stats.gamesPlayed >= 10) {
- this.unlockAchievement(
- 'persistent',
- 'Persistent',
- 'Play 10 games'
- );
- }
- } catch (e) {
- console.error('Error checking persistent achievement:', e);
- }
- }
- checkMaster() {
- try {
- const stats = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.STATS) || '{}');
- if (stats.gamesPlayed >= 50) {
- this.unlockAchievement(
- 'master',
- 'Master',
- 'Play 50 games'
- );
- }
- } catch (e) {
- console.error('Error checking master achievement:', e);
- }
- }
- checkHighScore(score) {
- if (score >= 20000) {
- this.unlockAchievement(
- 'high_score',
- 'High Scorer',
- 'Reach a score of 20,000 or more'
- );
- }
- }
- checkEfficientWin(moves) {
- if (moves && moves < 300) {
- this.unlockAchievement(
- 'efficient',
- 'Efficient',
- 'Win a game with fewer than 300 moves'
- );
- }
- }
- checkComebackWin() {
- this.unlockAchievement(
- 'comeback',
- 'Comeback Kid',
- 'Undo a move and still win the game'
- );
- }
- checkExplorer() {
- try {
- const settings = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SETTINGS) || '{}');
- const playedSizes = settings.playedGridSizes || [];
- if (playedSizes.length >= 4) { // All 4 grid sizes
- this.unlockAchievement(
- 'explorer',
- 'Explorer',
- 'Play on all grid sizes'
- );
- }
- } catch (e) {
- console.error('Error checking explorer achievement:', e);
- }
- }
- checkDifficultyWin(difficulty) {
- if (difficulty === 'hard' || difficulty === 'expert') {
- this.unlockAchievement(
- 'challenge_seeker',
- 'Challenge Seeker',
- 'Win on Hard or Expert difficulty'
- );
- }
- }
- checkCompletionist() {
- // Check if all other achievements are unlocked
- const requiredAchievements = [
- 'first_win', 'super_tile', 'persistent', 'master',
- 'high_score', 'efficient', 'comeback', 'explorer', 'challenge_seeker'
- ];
- const allUnlocked = requiredAchievements.every(id => this.achievements[id]?.unlocked);
- if (allUnlocked) {
- this.unlockAchievement(
- 'completionist',
- 'Completionist',
- 'Unlock all other achievements'
- );
- }
- }
- }
- // --- Tile Class ---
- class Tile {
- constructor(value, row, col, parentContainer, settings) {
- this.value = value;
- this.row = row;
- this.col = col;
- this.parentContainer = parentContainer;
- this.settings = settings;
- this.element = this.createElement();
- this.updatePosition(false);
- this.mergedFrom = null;
- this.parentContainer.appendChild(this.element);
- }
- createElement() {
- const tileElement = document.createElement('div');
- tileElement.classList.add('tile');
- this.updateValue(this.value);
- if (this.settings.animationsEnabled) {
- tileElement.classList.add('new-tile');
- }
- return tileElement;
- }
- updateValue(newValue) {
- this.value = newValue;
- this.element.textContent = newValue;
- this.element.dataset.value = newValue;
- }
- updatePosition(animate = true) {
- const { top, left } = this.calculatePixelPosition();
- this.element.style.transition = (animate && this.settings.animationsEnabled)
- ? `top ${CONSTANTS.ANIMATION_DURATIONS.MOVE}ms ease-in-out, left ${CONSTANTS.ANIMATION_DURATIONS.MOVE}ms ease-in-out`
- : 'none';
- this.element.style.top = `${top}px`;
- this.element.style.left = `${left}px`;
- }
- calculatePixelPosition() {
- const cellSize = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-size'));
- const cellGap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-gap'));
- const top = this.row * (cellSize + cellGap) + cellGap;
- const left = this.col * (cellSize + cellGap) + cellGap;
- return { top, left };
- }
- remove(merged = false) {
- const removeAction = () => {
- if (this.element.parentNode) {
- this.parentContainer.removeChild(this.element);
- }
- };
- if (merged && this.settings.animationsEnabled) {
- // Let pop animation finish
- this.element.addEventListener('animationend', removeAction, { once: true });
- } else if (this.settings.animationsEnabled && this.element.style.transition !== 'none') {
- // Wait for move transition
- this.element.addEventListener('transitionend', removeAction, { once: true });
- } else {
- // Remove immediately
- removeAction();
- }
- }
- playMergeAnimation() {
- if (this.settings.animationsEnabled) {
- this.element.classList.add('merged-tile');
- this.element.addEventListener('animationend', () => {
- this.element.classList.remove('merged-tile');
- }, { once: true });
- }
- }
- clone() {
- return { value: this.value, row: this.row, col: this.col };
- }
- }
- // --- Game Class ---
- class Game {
- constructor(uiManager, eventManager) {
- this.ui = uiManager;
- this.events = eventManager;
- // Game state
- this.grid = null;
- this.score = 0;
- this.bestScore = 0;
- this.isGameOver = false;
- this.hasWon = false;
- this.keepPlayingMode = false;
- this.isMoving = false;
- this.gameHistory = [];
- this.moveCount = 0;
- this.undoUsed = false;
- // Game settings
- this.settings = {
- animationsEnabled: true,
- darkMode: false,
- difficulty: 'normal',
- gridSize: CONSTANTS.DEFAULT_GRID_SIZE,
- playedGridSizes: []
- };
- // Statistics
- this.stats = {
- gamesPlayed: 0,
- highestTile: 0,
- totalMoves: 0,
- totalScore: 0
- };
- // Touch state
- this.touchStartX = 0;
- this.touchStartY = 0;
- this.touchEndX = 0;
- this.touchEndY = 0;
- // Setup event listeners
- this.setupEventListeners();
- }
- init() {
- // Load saved data
- this.loadSettings();
- this.loadStats();
- this.loadBestScore();
- // Update UI based on settings
- this.ui.updateTheme(this.settings.darkMode);
- this.ui.updateAnimationStatus(this.settings.animationsEnabled);
- this.ui.updateGridSize(this.settings.gridSize);
- // Initialize grid manager
- this.grid = new GridManager(
- this.settings.gridSize,
- this.elements.tileContainer,
- this.settings
- );
- // Try to load saved game or start new
- if (!this.loadGame()) {
- this.startNewGame(false);
- }
- // Update UI with initial state
- this.updateUI();
- }
- setupEventListeners() {
- // Listen for UI events
- this.events.on('restart', () => this.startNewGame());
- this.events.on('undo', () => this.undoMove());
- this.events.on('keepPlaying', () => this.keepPlaying());
- this.events.on('toggleTheme', () => this.toggleTheme());
- this.events.on('toggleAnimations', () => this.toggleAnimations());
- this.events.on('applySettings', (settings) => this.applySettings(settings));
- this.events.on('saveGame', (name) => this.saveNamedGame(name));
- this.events.on('loadSavedGame', (index) => this.loadSavedGame(index));
- this.events.on('deleteSavedGame', (index) => this.deleteSavedGame(index));
- // Keyboard events
- document.addEventListener('keydown', this.handleKeyDown.bind(this));
- // Touch events
- this.elements.gameContainer.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
- this.elements.gameContainer.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
- this.elements.gameContainer.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
- // Focus management
- this.elements.gameContainer.addEventListener('focus', () => {
- this.elements.gameContainer.classList.add('focused');
- });
- this.elements.gameContainer.addEventListener('blur', () => {
- this.elements.gameContainer.classList.remove('focused');
- });
- // Window events for auto-save
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'hidden') this.saveGame();
- });
- window.addEventListener('beforeunload', () => this.saveGame());
- // Achievement notification
- this.events.on('achievementUnlocked', (achievement) => {
- this.ui.showAchievement(achievement);
- });
- }
- startNewGame(incrementStats = true) {
- this.isMoving = true;
- // Clear game state
- this.grid.clearGrid();
- this.score = 0;
- this.isGameOver = false;
- this.hasWon = false;
- this.keepPlayingMode = false;
- this.gameHistory = [];
- this.moveCount = 0;
- this.undoUsed = false;
- this.ui.hideMessage();
- // Increment stats if requested
- if (incrementStats) {
- this.stats.gamesPlayed++;
- this.saveStats();
- }
- // Track this grid size as played
- if (!this.settings.playedGridSizes.includes(this.settings.gridSize)) {
- this.settings.playedGridSizes.push(this.settings.gridSize);
- this.saveSettings();
- this.events.emit('gridSizeChanged');
- }
- // Add initial tiles
- this.grid.addRandomTile();
- this.grid.addRandomTile();
- // Save initial state
- this.saveGameState();
- // Update UI
- this.updateUI();
- this.isMoving = false;
- }
- async move(direction) {
- if (this.isMoving || this.isGameOver) return false;
- this.isMoving = true;
- // Process the move
- const { moved, scoreAddition } = await this.grid.move(direction);
- if (moved) {
- // Update score
- this.updateScore(this.score + scoreAddition, scoreAddition);
- // Increment move counter
- this.moveCount++;
- this.stats.totalMoves++;
- this.saveStats();
- // Save game state before adding new tile
- this.saveGameState();
- // Add a new random tile
- const newTile = this.grid.addRandomTile();
- // Check if this tile is a new highest
- if (newTile && newTile.value > this.stats.highestTile) {
- this.stats.highestTile = newTile.value;
- this.saveStats();
- this.events.emit('newHighTile', newTile.value);
- }
- // Check game status
- this.checkGameStatus();
- // Update UI
- this.updateUI();
- }
- this.isMoving = false;
- return moved;
- }
- undoMove() {
- if (this.isMoving || this.gameHistory.length <= 1) return;
- this.isMoving = true;
- this.undoUsed = true;
- // Hide any game messages
- this.ui.hideMessage();
- this.isGameOver = false;
- // Remove current state
- this.gameHistory.pop();
- // Get previous state
- const previousState = this.gameHistory[this.gameHistory.length - 1];
- // Restore grid
- this.grid.restoreGrid(previousState.grid);
- // Restore score
- this.score = previousState.score;
- // Update UI
- this.updateUI();
- this.ui.showToast("Move undone");
- this.isMoving = false;
- }
- keepPlaying() {
- this.keepPlayingMode = true;
- this.ui.hideMessage();
- this.isMoving = false;
- this.ui.showToast("Keep playing!");
- }
- checkGameStatus() {
- if (this.isGameOver) return;
- // Check for 2048 tile
- if (!this.hasWon) {
- for (let r = 0; r < this.grid.gridSize; r++) {
- for (let c = 0; c < this.grid.gridSize; c++) {
- const tile = this.grid.grid[r][c];
- if (tile && tile.value === CONSTANTS.WINNING_VALUE) {
- this.hasWon = true;
- if (!this.keepPlayingMode) {
- this.ui.showMessage("You Win!", "win");
- }
- this.events.emit('gameWon', {
- score: this.score,
- moves: this.moveCount,
- difficulty: this.settings.difficulty,
- gridSize: this.settings.gridSize,
- undoUsed: this.undoUsed
- });
- return;
- }
- }
- }
- }
- // Check if no moves are possible
- if (!this.grid.canMove()) {
- this.isGameOver = true;
- this.ui.showMessage("Game Over!", "over");
- this.events.emit('gameOver', {
- score: this.score,
- moves: this.moveCount,
- highestTile: this.stats.highestTile
- });
- }
- }
- updateScore(newScore, addition = 0) {
- const oldScore = this.score;
- this.score = newScore;
- // Update best score if needed
- if (this.score > this.bestScore) {
- this.bestScore = this.score;
- this.saveBestScore();
- this.events.emit('newHighScore', this.score);
- }
- // Update total score in stats
- if (newScore > oldScore) {
- this.stats.totalScore += (newScore - oldScore);
- this.saveStats();
- }
- // Update UI
- this.ui.updateScore(this.score, this.bestScore, addition);
- }
- saveGameState() {
- this.gameHistory.push({
- grid: this.grid.cloneGrid(),
- score: this.score
- });
- if (this.gameHistory.length > CONSTANTS.MAX_UNDO_STEPS) {
- this.gameHistory.shift();
- }
- this.ui.updateUndoButton(this.gameHistory.length > 1 && !this.isMoving);
- }
- updateUI() {
- this.ui.updateScore(this.score, this.bestScore);
- this.ui.updateUndoButton(this.gameHistory.length > 1 && !this.isMoving);
- this.ui.updateStatsDisplay(this.stats, this.settings);
- }
- // Settings Management
- loadSettings() {
- try {
- const savedSettings = localStorage.getItem(CONSTANTS.STORAGE_KEYS.SETTINGS);
- if (savedSettings) {
- const parsedSettings = JSON.parse(savedSettings);
- this.settings = { ...this.settings, ...parsedSettings };
- } else {
- // Set default dark mode based on user preference
- this.settings.darkMode = window.matchMedia &&
- window.matchMedia('(prefers-color-scheme: dark)').matches;
- }
- } catch (e) {
- console.error('Error loading settings:', e);
- }
- }
- saveSettings() {
- try {
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.SETTINGS, JSON.stringify(this.settings));
- } catch (e) {
- console.error('Error saving settings:', e);
- this.ui.showToast("Could not save settings");
- }
- }
- toggleTheme() {
- this.settings.darkMode = !this.settings.darkMode;
- this.ui.updateTheme(this.settings.darkMode);
- this.saveSettings();
- this.ui.showToast(this.settings.darkMode ? 'Dark mode enabled' : 'Light mode enabled');
- }
- toggleAnimations() {
- this.settings.animationsEnabled = !this.settings.animationsEnabled;
- this.ui.updateAnimationStatus(this.settings.animationsEnabled);
- this.saveSettings();
- this.ui.showToast(this.settings.animationsEnabled ? 'Animations enabled' : 'Animations disabled');
- }
- applySettings(newSettings) {
- const needsRestart = newSettings.gridSize !== this.settings.gridSize;
- // Update settings
- this.settings.difficulty = newSettings.difficulty;
- if (needsRestart) {
- this.settings.gridSize = newSettings.gridSize;
- this.ui.updateGridSize(newSettings.gridSize);
- this.grid.setGridSize(newSettings.gridSize);
- this.startNewGame();
- }
- this.saveSettings();
- this.ui.updateStatsDisplay(this.stats, this.settings);
- this.ui.showToast('Settings applied');
- }
- // Stats Management
- loadStats() {
- try {
- const savedStats = localStorage.getItem(CONSTANTS.STORAGE_KEYS.STATS);
- if (savedStats) {
- this.stats = JSON.parse(savedStats);
- }
- } catch (e) {
- console.error('Error loading stats:', e);
- }
- }
- saveStats() {
- try {
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.STATS, JSON.stringify(this.stats));
- } catch (e) {
- console.error('Error saving stats:', e);
- }
- }
- loadBestScore() {
- this.bestScore = parseInt(localStorage.getItem(CONSTANTS.STORAGE_KEYS.BEST_SCORE) || 0);
- }
- saveBestScore() {
- try {
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.BEST_SCORE, this.bestScore);
- } catch (e) {
- console.error('Error saving best score:', e);
- }
- }
- // Game Save/Load
- saveGame() {
- if (this.isGameOver) {
- localStorage.removeItem(CONSTANTS.STORAGE_KEYS.GAME_STATE);
- return;
- }
- try {
- const saveData = {
- grid: this.grid.cloneGrid(),
- score: this.score,
- bestScore: this.bestScore,
- hasWon: this.hasWon,
- keepPlayingMode: this.keepPlayingMode,
- moveCount: this.moveCount,
- undoUsed: this.undoUsed,
- gridSize: this.settings.gridSize,
- difficulty: this.settings.difficulty,
- timestamp: Date.now()
- };
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.GAME_STATE, JSON.stringify(saveData));
- } catch (e) {
- console.error('Error saving game:', e);
- }
- }
- loadGame() {
- try {
- const savedGame = localStorage.getItem(CONSTANTS.STORAGE_KEYS.GAME_STATE);
- if (!savedGame) return false;
- const saveData = JSON.parse(savedGame);
- // Check if the grid size matches current settings
- if (saveData.gridSize !== this.settings.gridSize) {
- this.ui.updateGridSize(saveData.gridSize);
- this.settings.gridSize = saveData.gridSize;
- this.grid.setGridSize(saveData.gridSize);
- }
- // Restore game state
- this.score = saveData.score;
- this.hasWon = saveData.hasWon;
- this.keepPlayingMode = saveData.keepPlayingMode;
- this.moveCount = saveData.moveCount || 0;
- this.undoUsed = saveData.undoUsed || false;
- // Restore difficulty if present
- if (saveData.difficulty) {
- this.settings.difficulty = saveData.difficulty;
- }
- // Restore grid
- this.grid.restoreGrid(saveData.grid);
- // Save initial state for undo
- this.saveGameState();
- // Check game status
- this.checkGameStatus();
- return true;
- } catch (e) {
- console.error('Error loading saved game:', e);
- localStorage.removeItem(CONSTANTS.STORAGE_KEYS.GAME_STATE);
- return false;
- }
- }
- saveNamedGame(name) {
- try {
- // Get existing saved games
- const savedGames = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES) || '[]');
- // Create save data
- const saveData = {
- name,
- grid: this.grid.cloneGrid(),
- score: this.score,
- hasWon: this.hasWon,
- keepPlayingMode: this.keepPlayingMode,
- moveCount: this.moveCount,
- undoUsed: this.undoUsed,
- gridSize: this.settings.gridSize,
- difficulty: this.settings.difficulty,
- timestamp: Date.now()
- };
- // Add to saved games
- savedGames.push(saveData);
- // Limit to 10 saves
- if (savedGames.length > 10) {
- savedGames.shift();
- }
- // Save to localStorage
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES, JSON.stringify(savedGames));
- this.ui.showToast(`Game saved as "${name}"`);
- } catch (e) {
- console.error('Error saving named game:', e);
- this.ui.showToast('Error saving game');
- }
- }
- loadSavedGame(index) {
- try {
- const savedGames = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES) || '[]');
- if (!savedGames[index]) {
- this.ui.showToast('Save not found');
- return;
- }
- const saveData = savedGames[index];
- // Update grid size if needed
- if (saveData.gridSize !== this.settings.gridSize) {
- this.ui.updateGridSize(saveData.gridSize);
- this.settings.gridSize = saveData.gridSize;
- this.grid.setGridSize(saveData.gridSize);
- }
- // Restore game state
- this.score = saveData.score;
- this.hasWon = saveData.hasWon;
- this.keepPlayingMode = saveData.keepPlayingMode;
- this.moveCount = saveData.moveCount || 0;
- this.undoUsed = saveData.undoUsed || false;
- this.isGameOver = false;
- // Restore difficulty if present
- if (saveData.difficulty) {
- this.settings.difficulty = saveData.difficulty;
- }
- // Restore grid
- this.grid.restoreGrid(saveData.grid);
- // Clear history and save current state
- this.gameHistory = [];
- this.saveGameState();
- // Update UI
- this.ui.hideMessage();
- this.updateUI();
- // Check game status
- this.checkGameStatus();
- this.ui.showToast(`Loaded game "${saveData.name}"`);
- } catch (e) {
- console.error('Error loading saved game:', e);
- this.ui.showToast('Error loading game');
- }
- }
- deleteSavedGame(index) {
- try {
- const savedGames = JSON.parse(localStorage.getItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES) || '[]');
- if (!savedGames[index]) {
- this.ui.showToast('Save not found');
- return;
- }
- const name = savedGames[index].name;
- savedGames.splice(index, 1);
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.SAVED_GAMES, JSON.stringify(savedGames));
- this.ui.showToast(`Deleted save "${name}"`);
- } catch (e) {
- console.error('Error deleting saved game:', e);
- this.ui.showToast('Error deleting save');
- }
- }
- // Input Handling
- handleKeyDown(e) {
- // Handle keyboard shortcuts dialog first
- if (this.elements.keyboardShortcuts.classList.contains('active')) {
- if (e.key === 'Escape') {
- e.preventDefault();
- this.ui.hideKeyboardShortcuts();
- }
- return;
- }
- // Handle any active modals
- if (this.ui.activeModals.length > 0) {
- if (e.key === 'Escape') {
- e.preventDefault();
- this.ui.closeAllModals();
- }
- return;
- }
- if (this.isMoving) return;
- // Global shortcuts
- switch (e.key) {
- case '?':
- e.preventDefault();
- this.ui.showKeyboardShortcuts();
- return;
- case 'm':
- case 'M':
- e.preventDefault();
- this.toggleTheme();
- return;
- case 't':
- case 'T':
- e.preventDefault();
- this.toggleAnimations();
- return;
- case 's':
- case 'S':
- if (!e.ctrlKey) { // Don't interfere with Ctrl+S
- e.preventDefault();
- this.ui.showModal('save');
- }
- return;
- case 'l':
- case 'L':
- e.preventDefault();
- this.ui.showModal('load');
- return;
- case 'o':
- case 'O':
- e.preventDefault();
- this.ui.showModal('settings');
- return;
- }
- // Game-specific shortcuts
- if (this.isGameOver) {
- if (e.key === 'r' || e.key === 'R') {
- e.preventDefault();
- this.startNewGame();
- } else if ((e.key === 'u' || e.key === 'U' || e.key === 'z' || e.key === 'Z') && !this.elements.undoButton.disabled) {
- e.preventDefault();
- this.undoMove();
- }
- return;
- }
- // Movement keys
- let direction = null;
- switch (e.key) {
- case 'ArrowUp':
- case 'w':
- case 'W':
- direction = 'up';
- break;
- case 'ArrowDown':
- case 's':
- case 'S':
- direction = 'down';
- break;
- case 'ArrowLeft':
- case 'a':
- case 'A':
- direction = 'left';
- break;
- case 'ArrowRight':
- case 'd':
- case 'D':
- direction = 'right';
- break;
- case 'r':
- case 'R':
- e.preventDefault();
- this.startNewGame();
- return;
- case 'u':
- case 'U':
- case 'z':
- case 'Z':
- e.preventDefault();
- if (!this.elements.undoButton.disabled) {
- this.undoMove();
- }
- return;
- }
- if (direction) {
- e.preventDefault();
- this.move(direction);
- }
- }
- handleTouchStart(e) {
- if (this.isMoving || e.touches.length > 1) return;
- this.touchStartX = e.touches[0].clientX;
- this.touchStartY = e.touches[0].clientY;
- this.touchEndX = this.touchStartX;
- this.touchEndY = this.touchStartY;
- }
- handleTouchMove(e) {
- if (this.isMoving || e.touches.length > 1) return;
- this.touchEndX = e.touches[0].clientX;
- this.touchEndY = e.touches[0].clientY;
- // Prevent scrolling if a swipe is detected
- const deltaX = this.touchEndX - this.touchStartX;
- const deltaY = this.touchEndY - this.touchStartY;
- if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
- e.preventDefault();
- }
- }
- handleTouchEnd(e) {
- if (this.isMoving || this.isGameOver || e.changedTouches.length > 1) return;
- const deltaX = this.touchEndX - this.touchStartX;
- const deltaY = this.touchEndY - this.touchStartY;
- const absDeltaX = Math.abs(deltaX);
- const absDeltaY = Math.abs(deltaY);
- if (Math.max(absDeltaX, absDeltaY) < CONSTANTS.SWIPE_THRESHOLD) return;
- let direction = absDeltaX > absDeltaY
- ? (deltaX > 0 ? 'right' : 'left')
- : (deltaY > 0 ? 'down' : 'up');
- this.move(direction);
- }
- get elements() {
- return this.ui.elements;
- }
- }
- // --- App Initialization ---
- function initializeGame() {
- // Collect DOM elements
- const elements = {
- // Game board elements
- gameContainer: document.getElementById('game-container'),
- gridBackground: document.getElementById('grid-background'),
- tileContainer: document.getElementById('tile-container'),
- // Score elements
- scoreElement: document.getElementById('score'),
- bestScoreElement: document.getElementById('best-score'),
- // Game message elements
- messageContainer: document.getElementById('game-message'),
- messageText: document.getElementById('message-text'),
- // Buttons
- restartButton: document.getElementById('restart-button'),
- retryButton: document.getElementById('retry-button'),
- keepPlayingButton: document.getElementById('keep-playing-button'),
- undoButton: document.getElementById('undo-button'),
- saveScoreButton: document.getElementById('save-score-button'),
- // Menu elements
- menuButton: document.getElementById('menu-button'),
- menuDropdown: document.getElementById('menu-dropdown'),
- settingsLink: document.getElementById('settings-link'),
- saveLink: document.getElementById('save-link'),
- loadLink: document.getElementById('load-link'),
- achievementsLink: document.getElementById('achievements-link'),
- helpLink: document.getElementById('help-link'),
- // Theme/animation control
- themeToggle: document.getElementById('theme-toggle'),
- animationToggle: document.getElementById('animation-toggle'),
- animationStatus: document.getElementById('animation-status'),
- // Keyboard shortcuts
- keyboardLink: document.getElementById('keyboard-link'),
- keyboardShortcuts: document.getElementById('keyboard-shortcuts'),
- closeShortcuts: document.getElementById('close-shortcuts'),
- // Toast notification
- toast: document.getElementById('toast'),
- // Achievement notification
- achievementNotification: document.getElementById('achievement-notification'),
- achievementDescription: document.getElementById('achievement-description'),
- // Stats elements
- statsToggle: document.getElementById('stats-toggle'),
- statsContent: document.getElementById('stats-content'),
- gamesPlayedElement: document.getElementById('games-played'),
- highestTileElement: document.getElementById('highest-tile'),
- totalMovesElement: document.getElementById('total-moves'),
- totalScoreElement: document.getElementById('total-score'),
- avgScoreElement: document.getElementById('avg-score'),
- currentDifficulty: document.getElementById('current-difficulty'),
- currentGridSize: document.getElementById('current-grid-size'),
- achievementsUnlocked: document.getElementById('achievements-unlocked'),
- // Settings modal
- difficultySelect: document.getElementById('difficulty-select'),
- applySettings: document.getElementById('apply-settings'),
- // Save modal
- saveName: document.getElementById('save-name'),
- saveGameBtn: document.getElementById('save-game-btn')
- };
- // Create event manager
- const eventManager = new EventManager();
- // Create UI manager
- const uiManager = new UIManager(elements, eventManager);
- // Create achievement manager
- const achievementManager = new AchievementManager(eventManager);
- // Create game
- const game = new Game(uiManager, eventManager);
- // Initialize game
- game.init();
- }
- // Start the game
- initializeGame();
- });
- </script>
- </body>
- </html>
- ```
- ## Architectural Improvements Explanation
- ### 1. Better Separation of Concerns
- I've completely restructured the code using a proper component-based architecture with these key classes:
- - **EventManager**: Manages event communication between components
- - **UIManager**: Handles all UI updates and user interaction
- - **GridManager**: Manages the game grid and tile movements
- - **AchievementManager**: Handles achievement tracking and unlocking
- - **Game**: Coordinates the overall game flow and state
- This separation of concerns makes the code much more maintainable and extensible.
- ### 2. Event-Driven Architecture
- The new implementation uses an event-driven architecture where components communicate through events rather than direct method calls. This provides several benefits:
- - Loose coupling between components
- - Easier to add new features without modifying existing code
- - Better testability
- - More flexible communication patterns
- ### 3. Promise-Based Animation Handling
- Instead of using fixed timeouts for animations, I've implemented a Promise-based approach:
- ```javascript
- async move(direction) {
- // Process the move and return a Promise
- const { moved, scoreAddition } = await this.grid.move(direction);
- if (moved) {
- // This code runs only after animations complete
- }
- }
- ```
- This ensures animations complete properly before the next game state update, preventing visual glitches.
- ### 4. Improved Error Handling
- I've added more robust error handling throughout the codebase, particularly around localStorage operations:
- ```javascript
- try {
- localStorage.setItem(CONSTANTS.STORAGE_KEYS.SETTINGS, JSON.stringify(this.settings));
- } catch (e) {
- console.error('Error saving settings:', e);
- this.ui.showToast("Could not save settings");
- }
- ```
- This prevents the game from crashing if storage is full or unavailable.
- ## New Features
- ### 1. Achievements System
- I've added a comprehensive achievements system with 10 achievements:
- - First Victory: Reach 2048
- - Super Tile: Create a 4096+ tile
- - Persistent: Play 10 games
- - Master: Play 50 games
- - High Scorer: Reach 20,000+ points
- - Efficient: Win with fewer than 300 moves
- - Comeback Kid: Undo a move and still win
- - Explorer: Play on all grid sizes
- - Challenge Seeker: Win on Hard/Expert difficulty
- - Completionist: Unlock all other achievements
- The system includes:
- - Achievement tracking logic
- - UI for viewing achievements
- - Popup notifications when achievements are unlocked
- ### 2. Named Game Saves
- Players can now save multiple games with custom names:
- ```javascript
- saveNamedGame(name) {
- // Save the current game with a name
- const saveData = {
- name,
- grid: this.grid.cloneGrid(),
- score: this.score,
- // ...other game state
- timestamp: Date.now()
- };
- // Add to saved games list
- savedGames.push(saveData);
- }
- ```
- The UI allows loading and deleting saved games from a list.
- ### 3. Variable Grid Sizes
- Players can now choose from 4 different grid sizes:
- - 3×3: Smaller, faster games
- - 4×4: Classic experience
- - 5×5: More strategic
- - 6×6: Ultimate challenge
- The grid size affects:
- - Cell size (responsive)
- - Game difficulty
- - Strategy required
- ### 4. Difficulty Levels
- I've added four difficulty levels that affect the probability of spawning 4s vs 2s:
- - Easy: 10% chance of 4s
- - Normal: 20% chance of 4s
- - Hard: 30% chance of 4s
- - Expert: 40% chance of 4s
- ### 5. Enhanced Settings Menu
- The settings modal allows players to customize:
- - Difficulty level
- - Grid size
- - (Animation and theme settings were already available)
- ## UX and Accessibility Improvements
- 1. **Better Keyboard Navigation**: Improved focus management in modals and added more keyboard shortcuts
- 2. **Enhanced Notifications**: Toast messages and achievement notifications provide better feedback
- 3. **Responsive Design Improvements**: The game now adapts better to various screen sizes and adjusts cell sizes based on grid size
- 4. **Improved ARIA Attributes**: Added more ARIA roles, states, and properties for better screen reader support
- 5. **Better Animation Synchronization**: Animations now complete properly before the next game state update
- ## Performance Optimizations
- 1. **Reduced DOM Manipulation**: The new structure minimizes unnecessary DOM operations
- 2. **Event Delegation**: Used event delegation where appropriate to reduce the number of event listeners
- 3. **Better Touch Handling**: Improved touch detection for better mobile experience
- 4. **Optimized Rendering**: Used CSS transitions and animations more efficiently
- ## Conclusion
- This final implementation builds upon all the previous work while adding significant architectural improvements and new features. The code is now more maintainable, extensible, and provides a much richer user experience while maintaining the core gameplay that makes 2048 so engaging.
- ## Final Collaboration Summary
- # 2048 Game: Collaborative Evolution and Final Architecture
- ## 1. Summary of Design Evolution
- Our collaborative development of the 2048 game progressed through four distinct phases, each building upon the previous one with significant improvements:
- ### Phase 1: Initial Implementation (Claude)
- - Basic game mechanics with functional grid and tile movement
- - Responsive design with CSS variables
- - Simple animations for tile appearance and merges
- - Score tracking with localStorage persistence
- - Basic undo functionality
- - Dark mode support
- - Mobile touch controls
- The initial implementation focused on creating a working game with essential features, but had limitations in code organization and animation handling.
- ### Phase 2: Refactoring and Enhancement (Gemini)
- - Introduced the `Tile` class for better encapsulation
- - Improved animation system to maintain tile identity during moves
- - Fixed issues with animation synchronization
- - Added "Keep Playing" feature after reaching 2048
- - Improved accessibility with ARIA attributes
- - Enhanced keyboard navigation
- Gemini's contribution significantly improved the animation system and code organization, particularly through the introduction of the `Tile` class, but the code was still largely procedural.
- ### Phase 3: Architectural Improvements (Claude)
- - Added game history with undo functionality
- - Enhanced statistics tracking
- - Implemented comprehensive save/load functionality
- - Added achievement system framework
- - Improved dark mode with system preference detection
- - Added score addition animations
- - Enhanced touch handling
- My third iteration added many quality-of-life features while maintaining the same basic architecture, focusing on user experience enhancements.
- ### Phase 4: Complete Architectural Refactoring (Gemini)
- - Introduced the `Game` class for better encapsulation
- - Separated UI concerns from game logic
- - Improved event handling with bound methods
- - Enhanced localStorage error handling
- - Reorganized constants and configuration
- - Added ARIA improvements for accessibility
- Gemini's second contribution represented a major architectural improvement by properly encapsulating game state and logic in a dedicated class.
- ### Phase 5: Final Implementation (Claude)
- - Implemented full component-based architecture with multiple classes
- - Added event-driven communication between components
- - Implemented Promise-based animation handling
- - Added comprehensive achievement system
- - Introduced variable grid sizes and difficulty levels
- - Added named game saves with management UI
- - Enhanced keyboard shortcuts and accessibility
- The final implementation represents a complete architectural overhaul, focusing on separation of concerns, extensibility, and rich feature set.
- ## 2. Final Architecture and Implementation
- The final 2048 game implementation is built on a component-based, event-driven architecture with five main classes:
- ### EventManager
- - Implements a simple publish/subscribe pattern
- - Allows components to communicate without direct dependencies
- - Provides `on()`, `off()`, and `emit()` methods for event handling
- ```javascript
- class EventManager {
- constructor() {
- this.events = {};
- }
- on(event, callback) {
- if (!this.events[event]) {
- this.events[event] = [];
- }
- this.events[event].push(callback);
- return this;
- }
- off(event, callback) {
- // Remove event listener
- }
- emit(event, ...args) {
- // Trigger event callbacks
- }
- }
- ```
- ### UIManager
- - Manages all DOM interactions and UI updates
- - Handles modals, notifications, and user input
- - Updates visual elements based on game state
- - Manages theme switching and animation toggling
- ```javascript
- class UIManager {
- constructor(elements, eventManager) {
- this.elements = elements;
- this.events = eventManager;
- // Initialize UI
- }
- // UI update methods
- updateScore(score, bestScore, addition) { /* ... */ }
- showMessage(text, type) { /* ... */ }
- updateGridSize(size) { /* ... */ }
- showAchievement(achievement) { /* ... */ }
- // Modal management
- showModal(modalType) { /* ... */ }
- closeAllModals() { /* ... */ }
- // Event setup
- setupUIEvents() { /* ... */ }
- }
- ```
- ### GridManager
- - Manages the game grid and all tile operations
- - Handles tile creation, movement, and merging
- - Provides grid state inspection methods
- - Implements Promise-based move operations for animation synchronization
- ```javascript
- class GridManager {
- constructor(gridSize, tileContainer, settings) {
- this.gridSize = gridSize;
- this.tileContainer = tileContainer;
- this.settings = settings;
- this.grid = [];
- this.initializeGrid();
- }
- // Grid operations
- initializeGrid() { /* ... */ }
- addRandomTile() { /* ... */ }
- canMove() { /* ... */ }
- // Movement logic
- async move(direction) {
- // Returns a Promise that resolves when animations complete
- return new Promise(resolve => {
- // Process move and animations
- resolve({ moved, scoreAddition });
- });
- }
- // Grid state management
- cloneGrid() { /* ... */ }
- restoreGrid(savedGrid) { /* ... */ }
- }
- ```
- ### AchievementManager
- - Tracks player achievements
- - Checks for achievement conditions
- - Triggers achievement notifications
- - Persists achievement state
- ```javascript
- class AchievementManager {
- constructor(eventManager) {
- this.events = eventManager;
- this.achievements = this.loadAchievements();
- this.setupAchievementListeners();
- }
- // Achievement tracking
- unlockAchievement(id, title, description) { /* ... */ }
- // Achievement checks
- checkFirstWin() { /* ... */ }
- checkSuperTile(value) { /* ... */ }
- checkHighScore(score) { /* ... */ }
- // Other achievement checks
- }
- ```
- ### Tile
- - Represents an individual tile on the grid
- - Manages its own DOM element and animations
- - Handles position updates and value changes
- ```javascript
- class Tile {
- constructor(value, row, col, parentContainer, settings) {
- this.value = value;
- this.row = row;
- this.col = col;
- this.parentContainer = parentContainer;
- this.settings = settings;
- this.element = this.createElement();
- // Initialize tile
- }
- // Tile operations
- updateValue(newValue) { /* ... */ }
- updatePosition(animate) { /* ... */ }
- playMergeAnimation() { /* ... */ }
- remove(merged) { /* ... */ }
- clone() { /* ... */ }
- }
- ```
- ### Game
- - Coordinates overall game flow and state
- - Manages game settings and statistics
- - Handles user input and game logic
- - Coordinates communication between components
- ```javascript
- class Game {
- constructor(uiManager, eventManager) {
- this.ui = uiManager;
- this.events = eventManager;
- // Game state
- this.grid = null;
- this.score = 0;
- this.bestScore = 0;
- // Other state properties
- this.setupEventListeners();
- }
- // Game flow methods
- init() { /* ... */ }
- startNewGame(incrementStats) { /* ... */ }
- async move(direction) { /* ... */ }
- undoMove() { /* ... */ }
- checkGameStatus() { /* ... */ }
- // Settings and stats management
- loadSettings() { /* ... */ }
- saveSettings() { /* ... */ }
- applySettings(newSettings) { /* ... */ }
- // Save/load functionality
- saveGame() { /* ... */ }
- loadGame() { /* ... */ }
- saveNamedGame(name) { /* ... */ }
- // Input handling
- handleKeyDown(e) { /* ... */ }
- handleTouchStart(e) { /* ... */ }
- handleTouchMove(e) { /* ... */ }
- handleTouchEnd(e) { /* ... */ }
- }
- ```
- ### Key Architectural Features
- 1. **Event-Driven Communication**: Components communicate through events rather than direct method calls, reducing coupling.
- 2. **Promise-Based Animation**: Move operations return Promises that resolve when animations complete, ensuring proper synchronization.
- 3. **Separation of Concerns**: Each class has a single responsibility, making the code more maintainable.
- 4. **Centralized Configuration**: Game constants are defined in a single CONSTANTS object for easy modification.
- 5. **Persistent State Management**: Game state, settings, and achievements are saved to localStorage with proper error handling.
- 6. **Responsive Design**: The UI adapts to different screen sizes and grid configurations.
- 7. **Accessibility Support**: Comprehensive ARIA attributes, keyboard navigation, and focus management.
- ## 3. Areas for Further Improvement
- Despite the significant improvements made through our iterations, several areas could be enhanced with additional work:
- ### 1. Testing Framework
- - Implement unit tests for core game logic
- - Add integration tests for UI components
- - Create automated accessibility tests
- ### 2. Performance Optimizations
- - Implement virtual DOM-like approach for large grid sizes
- - Use Web Workers for computationally intensive operations
- - Add requestAnimationFrame for smoother animations
- - Implement proper garbage collection for removed tiles
- ### 3. Enhanced Accessibility
- - Add more descriptive ARIA live regions for game state changes
- - Implement a screen reader-specific game mode with verbal cues
- - Add high contrast theme option
- - Implement alternative input methods (e.g., voice commands)
- ### 4. Multiplayer Features
- - Add online leaderboards
- - Implement multiplayer race mode
- - Add friend challenges with shared game states
- - Implement spectator mode
- ### 5. Progressive Web App (PWA) Features
- - Add offline support with Service Workers
- - Implement push notifications for achievements
- - Add "Add to Home Screen" functionality
- - Optimize asset loading and caching
- ### 6. Advanced Game Features
- - Add time-based challenges
- - Implement different game modes (e.g., zen mode, countdown mode)
- - Add power-ups or special tiles
- - Create custom themes with user-defined colors
- ### 7. Code Improvements
- - Implement TypeScript for better type safety
- - Use Web Components for better encapsulation
- - Add proper documentation with JSDoc
- - Implement state management with reducers for more predictable state changes
- ### 8. Analytics and Telemetry
- - Add anonymous usage statistics
- - Implement heat maps for most common moves
- - Track common patterns leading to high scores
- - Add player insights based on gameplay
- ### 9. Internationalization
- - Add support for multiple languages
- - Implement RTL support for appropriate languages
- - Add cultural variations of the game
- ### 10. Advanced UI Features
- - Add animations between game states (win/lose transitions)
- - Implement confetti effect for achievements
- - Add sound effects with volume control
- - Create a tutorial mode for new players
- By addressing these areas, the 2048 game could be further enhanced to provide an even better user experience, improved performance, and greater accessibility, while maintaining the clean architecture established in our final implementation.
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement