2048 update

This commit is contained in:
Jevinca Marvella 2025-11-29 20:39:08 +07:00
parent 07be893964
commit e2ba499ce6
3 changed files with 1638 additions and 483 deletions

1205
2048.css

File diff suppressed because it is too large Load Diff

168
2048.html
View File

@ -1,25 +1,171 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048</title>
<link rel="stylesheet" href="2048.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<!-- ADD: ambient particle / neon background layer -->
<!-- Background Effects -->
<div class="particles" aria-hidden="true"></div>
<div class="starfield"></div>
<div class="cursor-light"></div>
<h1>2048</h1>
<div id="top-menu">
<button class="btn" onclick="restartGame()">Restart</button>
<button class="btn" onclick="goHome()">Home</button>
<div class="starfield" aria-hidden="true"></div>
<div class="cursor-light" aria-hidden="true"></div>
<!-- Top Right Controls -->
<div class="top-controls">
<button class="icon-btn btn-tutorial" id="btn-tutorial" title="How to Play">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</button>
<button class="icon-btn btn-restart-icon" id="btn-restart" title="Restart Game">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
</button>
</div>
<hr>
<h2>Score: <span id="score">0</span></h2>
<div id="board">
<!-- Game Container -->
<div class="game-container">
<!-- Header: Title + Scores -->
<div class="game-header">
<h1>2048</h1>
<div class="score-container">
<div class="score-box">
<div class="score-label">SCORE</div>
<div class="score-value" id="score">0</div>
</div>
<div class="score-box">
<div class="score-label">HIGH SCORE</div>
<div class="score-value" id="best-score">0</div>
</div>
</div>
</div>
<!-- Game Board -->
<div id="board"></div>
</div>
<!-- Tutorial Modal -->
<div class="tutorial-overlay" id="tutorial-overlay" style="display: none;">
<div class="tutorial-modal">
<button class="modal-close" id="close-tutorial">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<h2 class="tutorial-title">How to Play</h2>
<div class="tutorial-content">
<!-- PC Controls - Hidden on Mobile -->
<div class="tutorial-section pc-controls">
<h3>🖥️ PC Controls</h3>
<div class="keys-container">
<!-- WASD Keys -->
<div class="keys-group">
<div class="keys-grid-wasd">
<div class="key-empty"></div>
<div class="key-box">W</div>
<div class="key-empty"></div>
<div class="key-box">A</div>
<div class="key-box">S</div>
<div class="key-box">D</div>
</div>
<p class="keys-label">WASD</p>
</div>
<div class="keys-separator">or</div>
<!-- Arrow Keys -->
<div class="keys-group">
<div class="keys-grid-arrow">
<div class="key-empty"></div>
<div class="key-box"></div>
<div class="key-empty"></div>
<div class="key-box"></div>
<div class="key-box"></div>
<div class="key-box"></div>
</div>
<p class="keys-label">Arrow Keys</p>
</div>
</div>
</div>
<!-- Mobile Controls - Hidden on PC -->
<div class="tutorial-section mobile-controls">
<h3>📱 Mobile Controls</h3>
<div class="swipe-demo">
<div class="swipe-icon">👆</div>
<p>Swipe in any direction</p>
</div>
</div>
<div class="tutorial-section">
<h3>🎯 Objective</h3>
<p class="objective-text">Combine tiles with the same numbers to reach <strong>2048</strong>!</p>
</div>
</div>
</div>
</div>
<!-- Game Over Modal -->
<div class="game-over-overlay" id="game-over-overlay" style="display: none;">
<div class="game-over-modal">
<!-- Close Button (X) -->
<button class="game-over-close" id="game-over-close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<!-- Title -->
<div class="game-over-title">No More Moves!</div>
<div class="game-over-subtitle">Game Over</div>
<!-- Score Section -->
<div class="game-over-score">
<div class="game-over-score-label">Your Score</div>
<div class="game-over-score-value" id="final-score">0</div>
<!-- New High Score Badge - show if new record -->
<div class="new-high-score" id="new-high-score-badge" style="display: none;">
New High Score
</div>
<!-- Best Score Display - show if NOT new record -->
<div class="best-score-display" id="best-score-display" style="display: none;">
<div class="best-score-label">High Score</div>
<div class="best-score-value" id="modal-best-score">0</div>
</div>
</div>
<!-- Action Buttons - ICON ONLY -->
<div class="game-over-buttons">
<!-- Restart Button -->
<button class="btn-game-icon btn-restart-game" id="btn-play-again" title="Restart Game">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
</button>
<!-- Home Button -->
<button class="btn-game-icon btn-home-game" id="btn-home" title="Back to Home">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
</div>
</div>
</div>
<script src="2048.js"></script>
</body>

738
2048.js
View File

@ -1,15 +1,17 @@
/* 2048.js Enhanced with animations, particles, and glows
Replace previous 2048.js content with this file.
*/
/* 2048.js — Complete Version with WASD + Interactive Merge Effects */
/* ------------------------
State & audio (kept)
State & Variables
------------------------ */
let board = [];
let score = 0;
let lastMoveDir = null; // 'left','right','up','down' or null
let currentScore = 0;
let bestScore = parseInt(localStorage.getItem('bestScore2048')) || 0;
let lastMoveDir = null;
let isMoving = false;
// --- Audio setup ---
/* ------------------------
Audio Setup
------------------------ */
const audio = {
bg: new Audio("bgmusic.mp3"),
pop: new Audio("pop.mp3"),
@ -33,24 +35,90 @@ function tryPlayBg() {
}
/* ------------------------
DOM ready
DOM Ready
------------------------ */
document.addEventListener("DOMContentLoaded", () => {
updateBestScoreDisplay();
setupBoard();
addNewTile();
addNewTile();
tryPlayBg();
document.addEventListener("keydown", handleKey);
setupAmbientCursor();
setupEventListeners();
});
/* ------------------------
Setup & rendering
Event Listeners Setup
------------------------ */
function setupEventListeners() {
// Tutorial Modal
const btnTutorial = document.getElementById('btn-tutorial');
const tutorialOverlay = document.getElementById('tutorial-overlay');
const closeTutorial = document.getElementById('close-tutorial');
if (btnTutorial) {
btnTutorial.addEventListener('click', function() {
tutorialOverlay.style.display = 'flex';
});
}
if (closeTutorial) {
closeTutorial.addEventListener('click', function() {
tutorialOverlay.style.display = 'none';
});
}
if (tutorialOverlay) {
tutorialOverlay.addEventListener('click', function(e) {
if (e.target === tutorialOverlay) {
tutorialOverlay.style.display = 'none';
}
});
}
// Restart button (top right)
const btnRestart = document.getElementById('btn-restart');
if (btnRestart) {
btnRestart.addEventListener('click', restartGame);
}
// Game over modal buttons
const btnPlayAgain = document.getElementById('btn-play-again');
const btnHome = document.getElementById('btn-home');
const gameOverClose = document.getElementById('game-over-close');
if (btnPlayAgain) {
btnPlayAgain.addEventListener('click', playAgain);
}
if (btnHome) {
btnHome.addEventListener('click', goHome);
}
// Close button (X) di game over modal
if (gameOverClose) {
gameOverClose.addEventListener('click', hideGameOver);
}
// Close game over when clicking outside modal
const gameOverOverlay = document.getElementById('game-over-overlay');
if (gameOverOverlay) {
gameOverOverlay.addEventListener('click', function(e) {
if (e.target === this) {
hideGameOver();
}
});
}
}
/* ------------------------
Setup & Rendering
------------------------ */
function setupBoard() {
board = [];
score = 0;
updateScore();
currentScore = 0;
updateScoreDisplay();
const container = document.getElementById("board");
if (!container) {
@ -71,65 +139,62 @@ function setupBoard() {
}
}
/* update single tile visual with small entrance based on last move */
/* Update single tile visual */
function updateTile(row, col, num) {
const tile = document.getElementById(`${row}-${col}`);
if (!tile) return;
// reset classes except base .tile
tile.className = "tile";
// ensure previous transforms cleared
tile.style.transform = "";
tile.style.opacity = "";
if (num > 0) {
tile.textContent = num;
tile.classList.add("tile-" + num);
// slide-illusion: appear from direction of last move
if (lastMoveDir) {
let tx = 0, ty = 0;
const gap = 22; // small px offset for feel
if (lastMoveDir === "left") tx = gap;
else if (lastMoveDir === "right") tx = -gap;
else if (lastMoveDir === "up") ty = gap;
else if (lastMoveDir === "down") ty = -gap;
// start slightly offset & transparent, then animate to 0
tile.style.transform = `translate(${tx}px, ${ty}px)`;
tile.style.opacity = "0.0";
// force reflow then animate back
void tile.offsetWidth;
tile.style.transition = "transform 0.14s cubic-bezier(.2,.8,.2,1), opacity 0.12s";
tile.style.transform = "";
tile.style.opacity = "1";
// cleanup transition after done
setTimeout(() => { tile.style.transition = ""; }, 160);
}
} else {
tile.textContent = "";
}
}
/* refresh whole board */
/* Refresh whole board */
function refreshBoard() {
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
updateTile(r, c, board[r][c]);
}
}
updateScore();
updateScoreDisplay();
}
/* score */
function updateScore() {
const el = document.getElementById("score");
if (el) el.textContent = score;
/* ------------------------
Score Management
------------------------ */
function updateScoreDisplay() {
const scoreEl = document.getElementById("score");
if (scoreEl) {
scoreEl.textContent = currentScore;
}
/* add new tile with pop animation */
if (currentScore > bestScore) {
bestScore = currentScore;
localStorage.setItem('bestScore2048', bestScore);
updateBestScoreDisplay();
}
}
function updateBestScoreDisplay() {
const bestScoreEl = document.getElementById('best-score');
if (bestScoreEl) {
bestScoreEl.textContent = bestScore;
}
}
function resetScore() {
currentScore = 0;
updateScoreDisplay();
}
/* ------------------------
Add New Tile
------------------------ */
function addNewTile() {
const empty = [];
for (let r = 0; r < 4; r++) {
@ -147,18 +212,13 @@ function addNewTile() {
if (tile) {
tile.classList.add("new");
playSound(audio.pop);
tile.addEventListener("animationend", function handler() {
tile.classList.remove("new");
tile.removeEventListener("animationend", handler);
});
updateTile(spot.r, spot.c, 2);
} else {
updateTile(spot.r, spot.c, 2);
setTimeout(() => tile.classList.remove("new"), 300);
}
updateTile(spot.r, spot.c, 2);
return true;
}
/* safe playSound */
/* Safe playSound */
function playSound(soundObj) {
try {
soundObj.currentTime = 0;
@ -167,7 +227,7 @@ function playSound(soundObj) {
}
/* ------------------------
Movement helpers (logic preserved)
Movement Logic
------------------------ */
function filterZero(row) {
return row.filter(n => n !== 0);
@ -176,6 +236,7 @@ function filterZero(row) {
function slide(row) {
row = filterZero(row);
let mergedThisMove = false;
let mergedPositions = []; // Track posisi yang merge
for (let i = 0; i < row.length - 1; i++) {
if (row[i] === row[i + 1]) {
@ -183,129 +244,225 @@ function slide(row) {
playSound(audio.merge);
if (navigator.vibrate) navigator.vibrate(28);
score += row[i];
currentScore += row[i];
row[i + 1] = 0;
mergedThisMove = true;
mergedPositions.push(i); // Simpan posisi merge
}
}
row = filterZero(row);
while (row.length < 4) row.push(0);
return { row, merged: mergedThisMove };
return { row, merged: mergedThisMove, mergedPositions };
}
function arraysEqual(a, b) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/* Move functions with Interactive Effects */
function moveLeft() {
let moved = false;
let mergedCells = [];
for (let r = 0; r < 4; r++) {
const { row: newRow } = slide(board[r]);
const { row: newRow, mergedPositions } = slide(board[r]);
if (!arraysEqual(newRow, board[r])) moved = true;
board[r] = newRow;
// Track merged cells untuk animasi
if (mergedPositions && mergedPositions.length > 0) {
mergedPositions.forEach(c => {
mergedCells.push({ r, c });
});
}
}
if (moved) {
refreshBoard();
// Trigger merge animation
mergedCells.forEach(cell => {
triggerMergeEffect(cell.r, cell.c);
});
}
if (moved) updateAfterMove();
return moved;
}
function moveRight() {
let moved = false;
let mergedCells = [];
for (let r = 0; r < 4; r++) {
let reversed = [...board[r]].reverse();
const { row: slid } = slide(reversed);
const { row: slid, mergedPositions } = slide(reversed);
let newRow = slid.reverse();
if (!arraysEqual(newRow, board[r])) moved = true;
board[r] = newRow;
// Track merged cells
if (mergedPositions && mergedPositions.length > 0) {
mergedPositions.forEach(pos => {
const c = 3 - pos; // Reverse position
mergedCells.push({ r, c });
});
}
}
if (moved) {
refreshBoard();
mergedCells.forEach(cell => {
triggerMergeEffect(cell.r, cell.c);
});
}
if (moved) updateAfterMove();
return moved;
}
function moveUp() {
let moved = false;
let mergedCells = [];
for (let c = 0; c < 4; c++) {
const col = [board[0][c], board[1][c], board[2][c], board[3][c]];
const { row: newCol } = slide(col);
const { row: newCol, mergedPositions } = slide(col);
for (let r = 0; r < 4; r++) {
if (board[r][c] !== newCol[r]) moved = true;
board[r][c] = newCol[r];
}
// Track merged cells
if (mergedPositions && mergedPositions.length > 0) {
mergedPositions.forEach(r => {
mergedCells.push({ r, c });
});
}
}
if (moved) {
refreshBoard();
mergedCells.forEach(cell => {
triggerMergeEffect(cell.r, cell.c);
});
}
if (moved) updateAfterMove();
return moved;
}
function moveDown() {
let moved = false;
let mergedCells = [];
for (let c = 0; c < 4; c++) {
const col = [board[3][c], board[2][c], board[1][c], board[0][c]];
const { row: slid } = slide(col);
const { row: slid, mergedPositions } = slide(col);
const newCol = slid.reverse();
for (let r = 0; r < 4; r++) {
if (board[r][c] !== newCol[r]) moved = true;
board[r][c] = newCol[r];
}
// Track merged cells
if (mergedPositions && mergedPositions.length > 0) {
mergedPositions.forEach(pos => {
const r = 3 - pos; // Reverse position
mergedCells.push({ r, c });
});
}
}
if (moved) {
refreshBoard();
mergedCells.forEach(cell => {
triggerMergeEffect(cell.r, cell.c);
});
}
if (moved) updateAfterMove();
return moved;
}
/* after move: refresh and reset lastMoveDir after small delay */
function updateAfterMove() {
// apply merge glow to merged tiles (scan for high values that were recently created)
refreshBoard();
updateScore();
// schedule dropping lastMoveDir after small delay so new tiles animate in direction
setTimeout(() => { lastMoveDir = null; }, 180);
}
/* ------------------------
Input handling (adds lastMoveDir + invalid-move shake)
Input Handling - WITH WASD SUPPORT
------------------------ */
function handleKey(e) {
if (isMoving) return;
let moved = false;
if (e.key === "ArrowLeft") { lastMoveDir = "left"; moved = moveLeft(); }
else if (e.key === "ArrowRight") { lastMoveDir = "right"; moved = moveRight(); }
else if (e.key === "ArrowUp") { lastMoveDir = "up"; moved = moveUp(); }
else if (e.key === "ArrowDown") { lastMoveDir = "down"; moved = moveDown(); }
// Arrow Keys
if (e.key === "ArrowLeft") {
e.preventDefault();
moved = moveLeft();
}
else if (e.key === "ArrowRight") {
e.preventDefault();
moved = moveRight();
}
else if (e.key === "ArrowUp") {
e.preventDefault();
moved = moveUp();
}
else if (e.key === "ArrowDown") {
e.preventDefault();
moved = moveDown();
}
// WASD Keys
else if (e.key === "a" || e.key === "A") {
e.preventDefault();
moved = moveLeft();
}
else if (e.key === "d" || e.key === "D") {
e.preventDefault();
moved = moveRight();
}
else if (e.key === "w" || e.key === "W") {
e.preventDefault();
moved = moveUp();
}
else if (e.key === "s" || e.key === "S") {
e.preventDefault();
moved = moveDown();
}
if (moved) {
// add tile + subtle delay so new tile animates from direction
isMoving = true;
setTimeout(() => {
addNewTile();
refreshBoard();
}, 70);
const added = addNewTile();
if (!added || !canMove()) {
setTimeout(() => showGameOver(), 300);
}
isMoving = false;
}, 100);
} else {
// show board shake
const b = document.getElementById("board");
if (b) {
b.classList.add("shake");
setTimeout(()=>b.classList.remove("shake"), 360);
setTimeout(()=>b.classList.remove("shake"), 400);
}
}
}
/* Check if any move is possible */
function canMove() {
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
if (board[r][c] === 0) return true;
}
}
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const current = board[r][c];
if (c < 3 && board[r][c + 1] === current) return true;
if (r < 3 && board[r + 1][c] === current) return true;
}
}
return false;
}
/* ------------------------
Restart & home
------------------------ */
function restartGame() {
setupBoard();
addNewTile();
addNewTile();
refreshBoard();
}
function goHome() {
try { audio.bg.pause(); audio.bg.currentTime = 0; } catch (e) {}
window.location.href = "Homepage.html";
}
/* ------------------------
Touch swipe
Touch Swipe
------------------------ */
let touchStartX = 0;
let touchStartY = 0;
document.addEventListener("touchstart", function (e) {
const t = e.touches[0];
touchStartX = t.clientX;
@ -313,195 +470,234 @@ document.addEventListener("touchstart", function (e) {
}, { passive: true });
document.addEventListener("touchend", function (e) {
if (isMoving) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStartX;
const dy = t.clientY - touchStartY;
let moved = false;
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30) {
if (dx > 0) { lastMoveDir = "right"; moveRight() && setTimeout(()=>{ addNewTile(); refreshBoard(); }, 70); }
else { lastMoveDir = "left"; moveLeft() && setTimeout(()=>{ addNewTile(); refreshBoard(); }, 70); }
if (dx > 0) {
moved = moveRight();
} else {
moved = moveLeft();
}
} else if (Math.abs(dy) > 30) {
if (dy > 0) { lastMoveDir = "down"; moveDown() && setTimeout(()=>{ addNewTile(); refreshBoard(); }, 70); }
else { lastMoveDir = "up"; moveUp() && setTimeout(()=>{ addNewTile(); refreshBoard(); }, 70); }
if (dy > 0) {
moved = moveDown();
} else {
moved = moveUp();
}
}
if (moved) {
isMoving = true;
setTimeout(() => {
const added = addNewTile();
if (!added || !canMove()) {
setTimeout(() => showGameOver(), 300);
}
isMoving = false;
}, 100);
}
}, { passive: true });
/* ------------------------
Ambient cursor light + merge particles
Game Controls
------------------------ */
function restartGame() {
hideGameOver();
resetScore();
setupBoard();
addNewTile();
addNewTile();
refreshBoard();
isMoving = false;
}
function playAgain() {
restartGame();
}
function goHome() {
try {
audio.bg.pause();
audio.bg.currentTime = 0;
} catch (e) {}
window.location.href = "Homepage.html";
}
/* ------------------------
Game Over Modal
------------------------ */
function showGameOver() {
const finalScore = currentScore;
const isNewHighScore = finalScore >= bestScore && finalScore > 0;
const finalScoreEl = document.getElementById('final-score');
if (finalScoreEl) {
finalScoreEl.textContent = finalScore;
}
const newHighScoreBadge = document.getElementById('new-high-score-badge');
const bestScoreDisplay = document.getElementById('best-score-display');
if (isNewHighScore) {
if (newHighScoreBadge) newHighScoreBadge.style.display = 'inline-flex';
if (bestScoreDisplay) bestScoreDisplay.style.display = 'none';
} else {
if (newHighScoreBadge) newHighScoreBadge.style.display = 'none';
if (bestScoreDisplay) bestScoreDisplay.style.display = 'block';
const modalBestScore = document.getElementById('modal-best-score');
if (modalBestScore) modalBestScore.textContent = bestScore;
}
const gameOverOverlay = document.getElementById('game-over-overlay');
if (gameOverOverlay) {
gameOverOverlay.style.display = 'flex';
}
}
function hideGameOver() {
const gameOverOverlay = document.getElementById('game-over-overlay');
if (gameOverOverlay) {
gameOverOverlay.style.display = 'none';
}
}
/* ------------------------
Visual Effects (Simplified)
------------------------ */
function setupAmbientCursor() {
const container = document.querySelector(".particles");
if (!container) return;
// create a subtle cursor-follow blob
const cursor = document.createElement("div");
cursor.className = "cursor-light";
container.appendChild(cursor);
let lastX = window.innerWidth/2, lastY = window.innerHeight/2;
document.addEventListener("mousemove", (e) => {
lastX = e.clientX; lastY = e.clientY;
cursor.style.left = lastX + "px";
cursor.style.top = lastY + "px";
});
// small periodic motion for background
setInterval(() => {
cursor.style.opacity = (0.4 + Math.random()*0.35).toString();
}, 900);
// Cursor light effect removed for performance
// Keeping function for compatibility
}
/* spawn merge particles at tile center */
function spawnMergeParticles(row, col, colorHex="#00eaff") {
const container = document.body;
const boardRect = document.getElementById("board").getBoundingClientRect();
const tileEl = document.getElementById(`${row}-${col}`);
if (!tileEl) return;
/* =============================================
INTERACTIVE MERGE EFFECTS
============================================= */
const tileRect = tileEl.getBoundingClientRect();
const cx = tileRect.left + tileRect.width/2;
const cy = tileRect.top + tileRect.height/2;
function triggerMergeEffect(row, col) {
const tile = document.getElementById(`${row}-${col}`);
if (!tile) return;
const particles = [];
const count = 10;
for (let i = 0; i < count; i++) {
const p = document.createElement("div");
p.className = "merge-particle";
p.style.background = colorHex;
p.style.left = (cx - 6) + "px";
p.style.top = (cy - 6) + "px";
p.style.opacity = "1";
p.style.transform = "translate(0,0) scale(1)";
document.body.appendChild(p);
particles.push(p);
// Add merge class for CSS animation
tile.classList.add('merge');
setTimeout(() => tile.classList.remove('merge'), 300);
// random flight vector
const angle = Math.random() * Math.PI * 2;
const dist = 24 + Math.random()*36;
const tx = Math.cos(angle) * dist;
const ty = Math.sin(angle) * dist;
const rot = (Math.random() * 360)|0;
p.animate([
{ transform: `translate(0,0) rotate(0deg) scale(1)`, opacity: 1 },
{ transform: `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(0.6)`, opacity: 0 }
// Create particle burst effect
createParticleBurst(tile);
// Add glow pulse
tile.style.boxShadow = '0 0 40px currentColor';
setTimeout(() => {
tile.style.boxShadow = '';
}, 300);
}
function createParticleBurst(tileElement) {
const rect = tileElement.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Get tile color
const tileValue = parseInt(tileElement.textContent);
const tileColor = getTileColor(tileValue);
// Create 8-12 particles
const particleCount = 8 + Math.floor(Math.random() * 5);
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = 'merge-particle';
particle.style.left = centerX + 'px';
particle.style.top = centerY + 'px';
particle.style.background = tileColor;
document.body.appendChild(particle);
// Random direction
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const velocity = 60 + Math.random() * 40;
const tx = Math.cos(angle) * velocity;
const ty = Math.sin(angle) * velocity;
// Animate particle
particle.animate([
{
transform: 'translate(0, 0) scale(1)',
opacity: 1
},
{
transform: `translate(${tx}px, ${ty}px) scale(0)`,
opacity: 0
}
], {
duration: 420 + Math.random()*240,
easing: "cubic-bezier(.2,.8,.2,1)",
fill: "forwards"
});
// cleanup
setTimeout(()=>{ try{ p.remove(); }catch(e){} }, 800 + Math.random()*400);
duration: 500 + Math.random() * 200,
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}).onfinish = () => particle.remove();
}
// Add score popup
createScorePopup(centerX, centerY, tileValue);
}
function createScorePopup(x, y, score) {
const popup = document.createElement('div');
popup.className = 'score-popup';
popup.textContent = '+' + score;
popup.style.left = x + 'px';
popup.style.top = y + 'px';
popup.style.position = 'fixed';
popup.style.fontSize = '24px';
popup.style.fontWeight = '900';
popup.style.color = '#ffd700';
popup.style.textShadow = '0 0 20px rgba(255, 215, 0, 0.8)';
popup.style.pointerEvents = 'none';
popup.style.zIndex = '9999';
popup.style.transform = 'translate(-50%, -50%)';
document.body.appendChild(popup);
popup.animate([
{
transform: 'translate(-50%, -50%) scale(0.5)',
opacity: 0
},
{
transform: 'translate(-50%, -70px) scale(1.2)',
opacity: 1,
offset: 0.3
},
{
transform: 'translate(-50%, -120px) scale(1)',
opacity: 0
}
], {
duration: 1000,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)'
}).onfinish = () => popup.remove();
}
function getTileColor(value) {
const colors = {
2: '#00eaff',
4: '#00ff99',
8: '#ff00ff',
16: '#ff0066',
32: '#ffaa00',
64: '#ff0000',
128: '#5f00ff',
256: '#00ffea',
512: '#ff00aa',
1024: '#00ffaa',
2048: '#ffd700'
};
return colors[value] || '#00eaff';
}
/* ------------------------
Optional: call spawn on merges
We don't track exact merge positions in slide() local scope here,
but we can detect new larger tiles after move vs before and spawn particles.
------------------------ */
function spawnMergesFromDiff(prev, next) {
// prev & next are 4x4 arrays
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
if (next[r][c] > 0 && prev[r][c] !== next[r][c]) {
// if new value appears that wasn't same in prev -> likely merged or moved; if it's > 2 we spawn small effect
if (next[r][c] >= 4) {
spawnMergeParticles(r, c, chooseColorForValue(next[r][c]));
const tileEl = document.getElementById(`${r}-${c}`);
if (tileEl) {
tileEl.classList.add("merge");
setTimeout(()=>tileEl.classList.remove("merge"), 260);
}
}
}
}
}
}
/* choose nice color for particle based on value */
function chooseColorForValue(n) {
if (n >= 2048) return "#ffd700";
if (n >= 1024) return "#00ffaa";
if (n >= 512) return "#ff00aa";
if (n >= 128) return "#5f00ff";
if (n >= 32) return "#ffaa00";
return "#00eaff";
}
/* We'll wrap move functions to produce prev snapshot, then spawn particles for merges detected */
function cloneBoard(b) {
const out = [];
for (let r = 0; r < 4; r++) out.push([...b[r]]);
return out;
}
/* Override move functions to spawn particles after move */
function moveLeft() {
const prev = cloneBoard(board);
let moved = false;
for (let r = 0; r < 4; r++) {
const { row: newRow } = slide(board[r]);
if (!arraysEqual(newRow, board[r])) moved = true;
board[r] = newRow;
}
if (moved) {
spawnMergesFromDiff(prev, board);
updateAfterMove();
}
return moved;
}
function moveRight() {
const prev = cloneBoard(board);
let moved = false;
for (let r = 0; r < 4; r++) {
let reversed = [...board[r]].reverse();
const { row: slid } = slide(reversed);
let newRow = slid.reverse();
if (!arraysEqual(newRow, board[r])) moved = true;
board[r] = newRow;
}
if (moved) {
spawnMergesFromDiff(prev, board);
updateAfterMove();
}
return moved;
}
function moveUp() {
const prev = cloneBoard(board);
let moved = false;
for (let c = 0; c < 4; c++) {
const col = [board[0][c], board[1][c], board[2][c], board[3][c]];
const { row: newCol } = slide(col);
for (let r = 0; r < 4; r++) {
if (board[r][c] !== newCol[r]) moved = true;
board[r][c] = newCol[r];
}
}
if (moved) {
spawnMergesFromDiff(prev, board);
updateAfterMove();
}
return moved;
}
function moveDown() {
const prev = cloneBoard(board);
let moved = false;
for (let c = 0; c < 4; c++) {
const col = [board[3][c], board[2][c], board[1][c], board[0][c]];
const { row: slid } = slide(col);
const newCol = slid.reverse();
for (let r = 0; r < 4; r++) {
if (board[r][c] !== newCol[r]) moved = true;
board[r][c] = newCol[r];
}
}
if (moved) {
spawnMergesFromDiff(prev, board);
updateAfterMove();
}
return moved;
}
/* ------------------------
End of file
End of File
------------------------ */