Comment section

This commit is contained in:
Jevinca Marvella 2025-12-16 02:06:46 +07:00
parent 4243584706
commit 677ad4ab06
5 changed files with 393 additions and 133 deletions

View File

@ -1,25 +1,43 @@
/* ------------------------ /* ==========================================
2. AUDIO MANAGER 2048 AUDIO - SOUND MANAGEMENT SYSTEM
------------------------ */ ==========================================
fitur:
1. 3 Audio objects (bg, pop, merge)
2. Volume control terpisah untuk setiap audio
3. Icon dinamis sesuai level volume
4. Panel volume dengan inputLock integration
========================================== */
/* ==========================================
AUDIO OBJECTS
========================================== */
const audio = { const audio = {
bg: new Audio("Background_Music.mp3"), bg: new Audio("Background_Music.mp3"), // Background music
pop: new Audio("Pop.mp3"), pop: new Audio("Pop.mp3"), // Sound saat tile spawn
merge: new Audio("Merge.mp3") merge: new Audio("Merge.mp3") // Sound saat tile merge
}; };
audio.bg.loop = true; audio.bg.loop = true; // Background music loop terus
// Update audio volumes based on state & sliders /* ==========================================
UPDATE VOLUME DARI STATE & SLIDERS
========================================== */
function updateAudioVolumes() { function updateAudioVolumes() {
// Formula: volume = enabled ? (slider / 100) : 0
audio.bg.volume = soundState.bg ? (volumeState.music / 100) : 0; audio.bg.volume = soundState.bg ? (volumeState.music / 100) : 0;
audio.pop.volume = soundState.pop ? (volumeState.pop / 100) : 0; audio.pop.volume = soundState.pop ? (volumeState.pop / 100) : 0;
audio.merge.volume = soundState.merge ? (volumeState.merge / 100) : 0; audio.merge.volume = soundState.merge ? (volumeState.merge / 100) : 0;
} }
/* ==========================================
PLAY BACKGROUND MUSIC (dengan unlock)
========================================== */
function tryPlayBg() { function tryPlayBg() {
if (!soundState.bg || volumeState.music === 0) return; if (!soundState.bg || volumeState.music === 0) return;
// Coba play, kalau di-block browser (autoplay policy)
audio.bg.play().catch(() => { audio.bg.play().catch(() => {
// Setup unlock: tunggu user interaction
const unlock = () => { const unlock = () => {
if (soundState.bg && volumeState.music > 0) audio.bg.play().catch(()=>{}); if (soundState.bg && volumeState.music > 0) audio.bg.play().catch(()=>{});
window.removeEventListener("keydown", unlock); window.removeEventListener("keydown", unlock);
@ -30,19 +48,24 @@ function tryPlayBg() {
}); });
} }
/* Safe playSound with mute check */ /* ==========================================
PLAY SOUND (dengan mute check)
========================================== */
function playSound(soundObj) { function playSound(soundObj) {
try { try {
// Guard: cek mute state sebelum play
if (soundObj === audio.pop && (!soundState.pop || volumeState.pop === 0)) return; if (soundObj === audio.pop && (!soundState.pop || volumeState.pop === 0)) return;
if (soundObj === audio.merge && (!soundState.merge || volumeState.merge === 0)) return; if (soundObj === audio.merge && (!soundState.merge || volumeState.merge === 0)) return;
if (soundObj === audio.bg && (!soundState.bg || volumeState.music === 0)) return; if (soundObj === audio.bg && (!soundState.bg || volumeState.music === 0)) return;
soundObj.currentTime = 0; soundObj.currentTime = 0; // Restart dari awal
soundObj.play().catch(() => {}); soundObj.play().catch(() => {}); // Play with error handling
} catch (e) {} } catch (e) {}
} }
// Initialize Volume Sliders /* ==========================================
INIT VOLUME SLIDERS - Setup Event Listeners
========================================== */
function initVolumeControl() { function initVolumeControl() {
updateAudioVolumes(); updateAudioVolumes();
@ -50,28 +73,33 @@ function initVolumeControl() {
const popSlider = document.getElementById('vol-pop'); const popSlider = document.getElementById('vol-pop');
const mergeSlider = document.getElementById('vol-merge'); const mergeSlider = document.getElementById('vol-merge');
/* --- MUSIC SLIDER --- */
if (musicSlider) { if (musicSlider) {
// Load nilai dari localStorage
musicSlider.value = volumeState.music; musicSlider.value = volumeState.music;
updateSliderFill(musicSlider, volumeState.music); updateSliderFill(musicSlider, volumeState.music);
document.getElementById('vol-music-display').textContent = volumeState.music + '%'; document.getElementById('vol-music-display').textContent = volumeState.music + '%';
// Event listener saat slider digeser
musicSlider.addEventListener('input', (e) => { musicSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value); const val = parseInt(e.target.value);
volumeState.music = val; volumeState.music = val; // Update state
audio.bg.volume = val / 100; // Direct update audio.bg.volume = val / 100; // Set volume langsung
localStorage.setItem('vol_music', val); localStorage.setItem('vol_music', val); // Save ke browser
document.getElementById('vol-music-display').textContent = val + '%'; document.getElementById('vol-music-display').textContent = val + '%';
updateSliderFill(e.target, val); updateSliderFill(e.target, val); // Update visual fill
updateMainSoundIcon(); updateMainSoundIcon(); // Update icon
// Auto play/pause background music
if (val > 0 && audio.bg.paused && soundState.bg) { if (val > 0 && audio.bg.paused && soundState.bg) {
tryPlayBg(); tryPlayBg(); // Play kalau volume > 0
} else if (val === 0) { } else if (val === 0) {
audio.bg.pause(); audio.bg.pause(); // Pause kalau volume = 0 (muted)
} }
}); });
} }
/* --- POP SLIDER (sama seperti music) --- */
if (popSlider) { if (popSlider) {
popSlider.value = volumeState.pop; popSlider.value = volumeState.pop;
updateSliderFill(popSlider, volumeState.pop); updateSliderFill(popSlider, volumeState.pop);
@ -88,6 +116,7 @@ function initVolumeControl() {
}); });
} }
/* --- MERGE SLIDER (sama seperti music) --- */
if (mergeSlider) { if (mergeSlider) {
mergeSlider.value = volumeState.merge; mergeSlider.value = volumeState.merge;
updateSliderFill(mergeSlider, volumeState.merge); updateSliderFill(mergeSlider, volumeState.merge);
@ -105,86 +134,110 @@ function initVolumeControl() {
} }
updateMainSoundIcon(); updateMainSoundIcon();
setupVolumePanelEvents(); setupVolumePanelEvents(); // Setup panel interactions
} }
/* ==========================================
SETUP PANEL EVENTS - Open/Close Logic
==========================================
Ini yang nge-link dengan inputLocked di Controls
========================================== */
function setupVolumePanelEvents() { function setupVolumePanelEvents() {
const btnSoundMain = document.getElementById('btn-sound-main'); const btnSoundMain = document.getElementById('btn-sound-main');
const volumePanel = document.getElementById('volume-panel'); const volumePanel = document.getElementById('volume-panel');
const volumeBackdrop = document.getElementById('volume-backdrop'); const volumeBackdrop = document.getElementById('volume-backdrop');
if (btnSoundMain && volumePanel) { if (btnSoundMain && volumePanel) {
// Event: Klik tombol sound main
btnSoundMain.addEventListener('click', (e) => { btnSoundMain.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation(); // Jangan trigger event di parent
const isActive = volumePanel.classList.contains('active'); const isActive = volumePanel.classList.contains('active');
if (isActive) { if (isActive) {
// TUTUP PANEL
volumePanel.classList.remove('active'); volumePanel.classList.remove('active');
if (volumeBackdrop) volumeBackdrop.classList.remove('active'); if (volumeBackdrop) volumeBackdrop.classList.remove('active');
inputLocked = false; // 🔓 buka panel -> input kembali inputLocked = false; // UNLOCK - game input aktif lagi
} else { } else {
// BUKA PANEL
volumePanel.classList.add('active'); volumePanel.classList.add('active');
if (volumeBackdrop) volumeBackdrop.classList.add('active'); if (volumeBackdrop) volumeBackdrop.classList.add('active');
inputLocked = true; // 🔒 panel sound aktif -> swipe & keyboard mati inputLocked = true; // LOCK - blokir swipe & keyboard
} }
}); });
// Close when clicking backdrop // Event: Klik backdrop (tutup panel)
volumeBackdrop.addEventListener('click', () => { volumeBackdrop.addEventListener('click', () => {
volumePanel.classList.remove('active'); volumePanel.classList.remove('active');
volumeBackdrop.classList.remove('active'); volumeBackdrop.classList.remove('active');
inputLocked = false; // 🔓 unlock inputLocked = false; // UNLOCK
}); });
// Close when clicking outside (desktop) // Event: Klik di luar panel (desktop)
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!volumePanel.contains(e.target) && if (!volumePanel.contains(e.target) &&
!btnSoundMain.contains(e.target) && !btnSoundMain.contains(e.target) &&
(!volumeBackdrop || !volumeBackdrop.contains(e.target))) { (!volumeBackdrop || !volumeBackdrop.contains(e.target))) {
volumePanel.classList.remove('active'); volumePanel.classList.remove('active');
if (volumeBackdrop) volumeBackdrop.classList.remove('active'); if (volumeBackdrop) volumeBackdrop.classList.remove('active');
inputLocked = false; // 🔓 unlock input inputLocked = false; // UNLOCK
} }
}); });
// Event: Klik di dalam panel (jangan tutup)
volumePanel.addEventListener('click', (e) => { volumePanel.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation(); // Prevent close
}); });
} }
} }
/* ==========================================
UPDATE VISUAL FILL SLIDER
========================================== */
function updateSliderFill(slider, value) { function updateSliderFill(slider, value) {
// Set CSS custom property untuk animasi fill
// Dipakai di CSS: background: linear-gradient(...)
slider.style.setProperty('--value', value + '%'); slider.style.setProperty('--value', value + '%');
} }
/* ==========================================
UPDATE ICON DINAMIS - Sesuai Volume Level
========================================== */
function updateMainSoundIcon() { function updateMainSoundIcon() {
const btnMain = document.getElementById('btn-sound-main'); const btnMain = document.getElementById('btn-sound-main');
if (!btnMain) return; if (!btnMain) return;
// Ambil semua icon
const iconFull = btnMain.querySelector('.sound-full'); const iconFull = btnMain.querySelector('.sound-full');
const iconMedium = btnMain.querySelector('.sound-medium'); const iconMedium = btnMain.querySelector('.sound-medium');
const iconLow = btnMain.querySelector('.sound-low'); const iconLow = btnMain.querySelector('.sound-low');
const iconMuted = btnMain.querySelector('.sound-muted'); const iconMuted = btnMain.querySelector('.sound-muted');
// Hitung rata-rata volume dari 3 slider
const totalVolume = volumeState.music + volumeState.pop + volumeState.merge; const totalVolume = volumeState.music + volumeState.pop + volumeState.merge;
const avgVolume = totalVolume / 3; const avgVolume = totalVolume / 3;
// Hide semua icon dulu
if (iconFull) iconFull.style.display = 'none'; if (iconFull) iconFull.style.display = 'none';
if (iconMedium) iconMedium.style.display = 'none'; if (iconMedium) iconMedium.style.display = 'none';
if (iconLow) iconLow.style.display = 'none'; if (iconLow) iconLow.style.display = 'none';
if (iconMuted) iconMuted.style.display = 'none'; if (iconMuted) iconMuted.style.display = 'none';
// Show icon yang sesuai dengan level volume
if (totalVolume === 0) { if (totalVolume === 0) {
// Semua muted
if (iconMuted) iconMuted.style.display = 'block'; if (iconMuted) iconMuted.style.display = 'block';
btnMain.classList.add('all-muted'); btnMain.classList.add('all-muted');
} else { } else {
btnMain.classList.remove('all-muted'); btnMain.classList.remove('all-muted');
if (avgVolume >= 60) { if (avgVolume >= 60) {
// Volume tinggi (≥60%)
if (iconFull) iconFull.style.display = 'block'; if (iconFull) iconFull.style.display = 'block';
} else if (avgVolume >= 30) { } else if (avgVolume >= 30) {
// Volume sedang (30-59%)
if (iconMedium) iconMedium.style.display = 'block'; if (iconMedium) iconMedium.style.display = 'block';
} else { } else {
// Volume rendah (1-29%)
if (iconLow) iconLow.style.display = 'block'; if (iconLow) iconLow.style.display = 'block';
} }
} }

View File

@ -1,48 +1,75 @@
/* ==========================================
2048 FLOATING PARTICLES - BACKGROUND DECORATION
==========================================
fungsi
- createParticle() - Buat partikel yang naik dari bawah
- Self-recycling system (partikel hilang buat baru)
- Efek drift horizontal untuk gerakan natural
========================================== */
// Floating Particles System - Particles rising from bottom // Floating Particles System - Particles rising from bottom
(function() { (function() {
// Ambil container untuk partikel
const container = document.getElementById('floating-particles'); const container = document.getElementById('floating-particles');
if (!container) return; if (!container) return; // Guard: kalau container nggak ada, skip
// Config warna partikel (5 warna neon)
const particleColors = ['cyan', 'pink', 'purple', 'green', 'orange']; const particleColors = ['cyan', 'pink', 'purple', 'green', 'orange'];
const particleCount = 25; // Number of particles const particleCount = 25; // Total partikel yang muncul
/* ==========================================
CREATE PARTICLE - Buat 1 partikel
========================================== */
function createParticle() { function createParticle() {
// Buat element div untuk partikel
const particle = document.createElement('div'); const particle = document.createElement('div');
particle.className = `floating-particle ${particleColors[Math.floor(Math.random() * particleColors.length)]}`;
// Random horizontal position // Pilih warna random dari array
const randomColor = particleColors[Math.floor(Math.random() * particleColors.length)];
particle.className = `floating-particle ${randomColor}`;
// POSISI HORIZONTAL RANDOM (0-100%)
const leftPos = Math.random() * 100; const leftPos = Math.random() * 100;
particle.style.left = leftPos + '%'; particle.style.left = leftPos + '%';
// Random drift amount (horizontal movement during float) // DRIFT HORIZONTAL (gerakan ke kiri/kanan saat naik)
const drift = (Math.random() - 0.5) * 150; // -75px to +75px // Range: -75px sampai +75px
const drift = (Math.random() - 0.5) * 150;
particle.style.setProperty('--drift', drift + 'px'); particle.style.setProperty('--drift', drift + 'px');
// Random animation duration (slower = more dramatic) // DURASI ANIMASI RANDOM (8-18 detik)
const duration = 8 + Math.random() * 10; // 8-18 seconds // Semakin lama = semakin smooth & dramatis
const duration = 8 + Math.random() * 10;
particle.style.animationDuration = duration + 's'; particle.style.animationDuration = duration + 's';
// Random delay for staggered effect // DELAY RANDOM (0-5 detik)
// Biar nggak semua partikel muncul bareng (staggered)
const delay = Math.random() * 5; const delay = Math.random() * 5;
particle.style.animationDelay = delay + 's'; particle.style.animationDelay = delay + 's';
// Random size variation // UKURAN RANDOM (6-14px)
const size = 6 + Math.random() * 8; // 6-14px const size = 6 + Math.random() * 8;
particle.style.width = size + 'px'; particle.style.width = size + 'px';
particle.style.height = size + 'px'; particle.style.height = size + 'px';
// Append ke container
container.appendChild(particle); container.appendChild(particle);
// Remove and recreate after animation completes // ♻️ SELF-RECYCLING SYSTEM
// Setelah animasi selesai → hapus & buat baru
setTimeout(() => { setTimeout(() => {
particle.remove(); particle.remove(); // Hapus partikel lama
createParticle(); createParticle(); // Buat partikel baru (infinite loop)
}, (duration + delay) * 1000); }, (duration + delay) * 1000); // Total waktu = duration + delay
} }
// Initialize particles /* ==========================================
INITIALIZE - Buat semua partikel awal
========================================== */
// Loop buat 25 partikel
for (let i = 0; i < particleCount; i++) { for (let i = 0; i < particleCount; i++) {
// Stagger initial creation // Stagger creation: jeda 200ms per partikel
// Biar nggak semua muncul sekaligus (smooth)
setTimeout(() => createParticle(), i * 200); setTimeout(() => createParticle(), i * 200);
} }
})(); })(); // IIFE (Immediately Invoked Function Expression) - langsung jalan

View File

@ -1,23 +1,42 @@
/* ------------------------ /* ==========================================
3. VISUAL EFFECTS 2048 VISUAL EFFECTS - ANIMATION SYSTEM
------------------------ */ ==========================================
fungsi utama:
1. triggerComboEffect() - Efek saat tile merge
2. showComboPopup() - Popup combo text (x2, x3, x4+)
3. createParticleBurst() - Ledakan partikel dari tile
4. createScorePopup() - Score yang terbang ke atas
5. getTileColor() - Warna sesuai nilai tile
========================================== */
/* ==========================================
TRIGGER COMBO EFFECT - Main Visual Handler
==========================================
Dipanggil dari move functions di 2048_Logic.js
========================================== */
function triggerComboEffect(mergedCells, comboCount) { function triggerComboEffect(mergedCells, comboCount) {
// Guard: kalau nggak ada tile yang merge, skip
if (mergedCells.length === 0) return; if (mergedCells.length === 0) return;
// Loop setiap tile yang di-merge
mergedCells.forEach(cell => { mergedCells.forEach(cell => {
const tile = document.getElementById(`${cell.r}-${cell.c}`); const tile = document.getElementById(`${cell.r}-${cell.c}`);
if (!tile) return; if (!tile) return;
// Efek 1: Animasi "merge" (scale + glow)
tile.classList.add('merge'); tile.classList.add('merge');
setTimeout(() => tile.classList.remove('merge'), 300); setTimeout(() => tile.classList.remove('merge'), 300);
// Efek 2: Ledakan partikel
createParticleBurst(tile); createParticleBurst(tile);
// Efek 3: Box shadow glow
tile.style.boxShadow = '0 0 40px currentColor'; tile.style.boxShadow = '0 0 40px currentColor';
setTimeout(() => { setTimeout(() => {
tile.style.boxShadow = ''; tile.style.boxShadow = ''; // Reset setelah 300ms
}, 300); }, 300);
// Efek 4: Score popup yang terbang ke atas
const rect = tile.getBoundingClientRect(); const rect = tile.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
@ -25,42 +44,54 @@ function triggerComboEffect(mergedCells, comboCount) {
createScorePopup(centerX, centerY, tileValue); createScorePopup(centerX, centerY, tileValue);
}); });
// Efek 5: Combo popup kalau merge ≥2 tile sekaligus
if (comboCount >= 2) { if (comboCount >= 2) {
showComboPopup(comboCount); showComboPopup(comboCount);
} }
} }
/* ==========================================
COMBO POPUP - Text "COMBO x2!", "AMAZING x3!", dst
========================================== */
function showComboPopup(comboCount) { function showComboPopup(comboCount) {
const board = document.getElementById('board'); const board = document.getElementById('board');
if (!board) return; if (!board) return;
// Hitung posisi tengah board
const rect = board.getBoundingClientRect(); const rect = board.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
// Buat element popup
const popup = document.createElement('div'); const popup = document.createElement('div');
popup.className = 'combo-popup'; popup.className = 'combo-popup';
popup.style.left = centerX + 'px'; popup.style.left = centerX + 'px';
popup.style.top = centerY + 'px'; popup.style.top = centerY + 'px';
popup.style.position = 'fixed'; popup.style.position = 'fixed';
popup.style.fontWeight = '900'; popup.style.fontWeight = '900';
popup.style.pointerEvents = 'none'; popup.style.pointerEvents = 'none'; // Nggak bisa diklik
popup.style.zIndex = '9999'; popup.style.zIndex = '9999'; // Di depan semua
popup.style.transform = 'translate(-50%, -50%)'; popup.style.transform = 'translate(-50%, -50%)'; // Center alignment
popup.style.textTransform = 'uppercase'; popup.style.textTransform = 'uppercase';
popup.style.letterSpacing = '3px'; popup.style.letterSpacing = '3px';
// Styling berbeda sesuai combo level
if (comboCount === 2) { if (comboCount === 2) {
// COMBO x2 - Hijau neon
popup.textContent = 'COMBO x2!'; popup.textContent = 'COMBO x2!';
popup.style.fontSize = '36px'; popup.style.fontSize = '36px';
popup.style.color = '#00ff99'; popup.style.color = '#00ff99';
popup.style.textShadow = '0 0 30px rgba(0, 255, 153, 1), 0 0 50px rgba(0, 255, 153, 0.5)'; popup.style.textShadow = '0 0 30px rgba(0, 255, 153, 1), 0 0 50px rgba(0, 255, 153, 0.5)';
} else if (comboCount === 3) { } else if (comboCount === 3) {
// AMAZING x3 - Pink magenta
popup.textContent = 'AMAZING x3!'; popup.textContent = 'AMAZING x3!';
popup.style.fontSize = '42px'; popup.style.fontSize = '42px';
popup.style.color = '#ff00ff'; popup.style.color = '#ff00ff';
popup.style.textShadow = '0 0 35px rgba(255, 0, 255, 1), 0 0 60px rgba(255, 0, 255, 0.6)'; popup.style.textShadow = '0 0 35px rgba(255, 0, 255, 1), 0 0 60px rgba(255, 0, 255, 0.6)';
} else if (comboCount >= 4) { } else if (comboCount >= 4) {
// PERFECT x4+ - Gold
popup.textContent = 'PERFECT x' + comboCount + '!'; popup.textContent = 'PERFECT x' + comboCount + '!';
popup.style.fontSize = '48px'; popup.style.fontSize = '48px';
popup.style.color = '#ffd700'; popup.style.color = '#ffd700';
@ -69,6 +100,7 @@ function showComboPopup(comboCount) {
document.body.appendChild(popup); document.body.appendChild(popup);
// Animasi: muncul → bounce → hilang
popup.animate([ popup.animate([
{ transform: 'translate(-50%, -50%) scale(0.3) rotate(-10deg)', opacity: 0 }, { transform: 'translate(-50%, -50%) scale(0.3) rotate(-10deg)', opacity: 0 },
{ transform: 'translate(-50%, -50%) scale(1.3) rotate(5deg)', opacity: 1, offset: 0.3 }, { transform: 'translate(-50%, -50%) scale(1.3) rotate(5deg)', opacity: 1, offset: 0.3 },
@ -76,54 +108,66 @@ function showComboPopup(comboCount) {
{ transform: 'translate(-50%, -50%) scale(0.8) rotate(0deg)', opacity: 0 } { transform: 'translate(-50%, -50%) scale(0.8) rotate(0deg)', opacity: 0 }
], { ], {
duration: 1200, duration: 1200,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' // Bounce effect
}).onfinish = () => popup.remove(); }).onfinish = () => popup.remove(); // Auto cleanup
} }
/* ==========================================
PARTICLE BURST - Ledakan partikel dari tile
========================================== */
function createParticleBurst(tileElement) { function createParticleBurst(tileElement) {
// Ambil posisi tengah tile
const rect = tileElement.getBoundingClientRect(); const rect = tileElement.getBoundingClientRect();
const centerX = rect.left + rect.width / 2; const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2; const centerY = rect.top + rect.height / 2;
// Ambil warna tile sesuai nilainya
const tileValue = parseInt(tileElement.textContent); const tileValue = parseInt(tileElement.textContent);
const tileColor = getTileColor(tileValue); const tileColor = getTileColor(tileValue);
// Jumlah partikel random (8-12)
const particleCount = 8 + Math.floor(Math.random() * 5); const particleCount = 8 + Math.floor(Math.random() * 5);
// Buat partikel dalam lingkaran (360°)
for (let i = 0; i < particleCount; i++) { for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div'); const particle = document.createElement('div');
particle.className = 'merge-particle'; particle.className = 'merge-particle';
particle.style.left = centerX + 'px'; particle.style.left = centerX + 'px';
particle.style.top = centerY + 'px'; particle.style.top = centerY + 'px';
particle.style.background = tileColor; particle.style.background = tileColor; // Warna sama dengan tile
document.body.appendChild(particle); document.body.appendChild(particle);
// Hitung sudut & kecepatan untuk ledakan melingkar
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5; const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
const velocity = 60 + Math.random() * 40; const velocity = 60 + Math.random() * 40; // 60-100px
const tx = Math.cos(angle) * velocity; const tx = Math.cos(angle) * velocity; // Posisi X
const ty = Math.sin(angle) * velocity; const ty = Math.sin(angle) * velocity; // Posisi Y
// Animasi: meledak keluar sambil mengecil
particle.animate([ particle.animate([
{ transform: 'translate(0, 0) scale(1)', opacity: 1 }, { transform: 'translate(0, 0) scale(1)', opacity: 1 },
{ transform: `translate(${tx}px, ${ty}px) scale(0)`, opacity: 0 } { transform: `translate(${tx}px, ${ty}px) scale(0)`, opacity: 0 }
], { ], {
duration: 500 + Math.random() * 200, duration: 500 + Math.random() * 200, // 500-700ms
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' // Smooth ease-out
}).onfinish = () => particle.remove(); }).onfinish = () => particle.remove(); // Auto cleanup
} }
} }
/* ==========================================
SCORE POPUP - Angka score yang terbang ke atas
========================================== */
function createScorePopup(x, y, score) { function createScorePopup(x, y, score) {
const popup = document.createElement('div'); const popup = document.createElement('div');
popup.className = 'score-popup'; popup.className = 'score-popup';
popup.textContent = '+' + score; popup.textContent = '+' + score; // Contoh: "+16", "+32"
popup.style.left = x + 'px'; popup.style.left = x + 'px';
popup.style.top = y + 'px'; popup.style.top = y + 'px';
popup.style.position = 'fixed'; popup.style.position = 'fixed';
popup.style.fontSize = '24px'; popup.style.fontSize = '24px';
popup.style.fontWeight = '900'; popup.style.fontWeight = '900';
popup.style.color = '#ffd700'; popup.style.color = '#ffd700'; // Gold
popup.style.textShadow = '0 0 20px rgba(255, 215, 0, 0.8)'; popup.style.textShadow = '0 0 20px rgba(255, 215, 0, 0.8)';
popup.style.pointerEvents = 'none'; popup.style.pointerEvents = 'none';
popup.style.zIndex = '9999'; popup.style.zIndex = '9999';
@ -131,21 +175,36 @@ function createScorePopup(x, y, score) {
document.body.appendChild(popup); document.body.appendChild(popup);
// Animasi: terbang ke atas sambil fade out
popup.animate([ popup.animate([
{ transform: 'translate(-50%, -50%) scale(0.5)', opacity: 0 }, { transform: 'translate(-50%, -50%) scale(0.5)', opacity: 0 },
{ transform: 'translate(-50%, -70px) scale(1.2)', opacity: 1, offset: 0.3 }, { transform: 'translate(-50%, -70px) scale(1.2)', opacity: 1, offset: 0.3 },
{ transform: 'translate(-50%, -120px) scale(1)', opacity: 0 } { transform: 'translate(-50%, -120px) scale(1)', opacity: 0 }
], { ], {
duration: 1000, duration: 1000,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' // Bounce ease
}).onfinish = () => popup.remove(); }).onfinish = () => popup.remove();
} }
/* ==========================================
GET TILE COLOR - Warna sesuai nilai tile
==========================================
Dipakai untuk partikel biar warnanya match
========================================== */
function getTileColor(value) { function getTileColor(value) {
const colors = { const colors = {
2: '#00eaff', 4: '#00ff99', 8: '#ff00ff', 16: '#ff0066', 2: '#00eaff', // Cyan
32: '#ffaa00', 64: '#ff0000', 128: '#5f00ff', 256: '#00ffea', 4: '#00ff99', // Green
512: '#ff00aa', 1024: '#00ffaa', 2048: '#ffd700' 8: '#ff00ff', // Magenta
16: '#ff0066', // Pink
32: '#ffaa00', // Orange
64: '#ff0000', // Red
128: '#5f00ff', // Purple
256: '#00ffea', // Cyan bright
512: '#ff00aa', // Pink bright
1024: '#00ffaa', // Green bright
2048: '#ffd700' // Gold (winning tile!)
}; };
// Return warna sesuai value, default cyan kalau nggak ada
return colors[value] || '#00eaff'; return colors[value] || '#00eaff';
} }

View File

@ -1,7 +1,20 @@
/* ==========================================
ANIMATION HOMEPAGE - Homepage Controller
==========================================
fitur utama:
1. updateAuthButton() - Toggle LOGIN/LOGOUT button
2. handleLogout() - Logout logic dengan PHP
3. Event listeners untuk semua button & keyboard
4. Smooth scroll & responsive handling
========================================== */
// Animation Homepage.js
(function() { (function() {
'use strict'; 'use strict';
// ==================== DOM ELEMENTS ==================== /* ==========================================
DOM ELEMENTS - Cache semua element penting
========================================== */
const elements = { const elements = {
logo: null, logo: null,
authBtn: null, authBtn: null,
@ -13,33 +26,37 @@
logoutFailedOverlay: null logoutFailedOverlay: null
}; };
// ==================== INITIALIZE ==================== /* ==========================================
INITIALIZE - Setup saat page load
========================================== */
function init() { function init() {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
// Kalau DOM belum ready, tunggu event
document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('DOMContentLoaded', initAll);
} else { } else {
// Kalau sudah ready, langsung init
initAll(); initAll();
} }
} }
function initAll() { function initAll() {
try { try {
// Cache DOM elements // Step 1: Cache semua DOM elements
cacheElements(); cacheElements();
// Validate elements // Step 2: Validasi element wajib ada
if (!validateElements()) { if (!validateElements()) {
console.error('Some required elements are missing'); console.error('Some required elements are missing');
return; return;
} }
// Setup event listeners // Step 3: Setup event listeners
setupEventListeners(); setupEventListeners();
// Initialize smooth scroll // Step 4: Initialize smooth scroll
initSmoothScroll(); initSmoothScroll();
// Check login status and update button // Step 5: Update button LOGIN/LOGOUT sesuai status
updateAuthButton(); updateAuthButton();
console.log('✅ Homepage initialized successfully'); console.log('✅ Homepage initialized successfully');
@ -48,7 +65,11 @@
} }
} }
// ==================== CACHE ELEMENTS ==================== /* ==========================================
CACHE ELEMENTS - Simpan reference ke variable
==========================================
Kenapa? Biar nggak query DOM berkali-kali (performa)
========================================== */
function cacheElements() { function cacheElements() {
elements.logo = document.querySelector('.logo'); elements.logo = document.querySelector('.logo');
elements.authBtn = document.getElementById('auth-button'); elements.authBtn = document.getElementById('auth-button');
@ -60,50 +81,60 @@
elements.logoutFailedOverlay = document.getElementById('logout-failed-overlay'); elements.logoutFailedOverlay = document.getElementById('logout-failed-overlay');
} }
// ==================== VALIDATE ELEMENTS ==================== /* ==========================================
VALIDATE ELEMENTS - Cek element wajib ada
========================================== */
function validateElements() { function validateElements() {
// Element yang HARUS ada: logo, authBtn, playBtn
const requiredElements = ['logo', 'authBtn', 'playBtn']; const requiredElements = ['logo', 'authBtn', 'playBtn'];
const missingElements = requiredElements.filter(key => !elements[key]); const missingElements = requiredElements.filter(key => !elements[key]);
if (missingElements.length > 0) { if (missingElements.length > 0) {
console.warn('Missing elements:', missingElements); console.warn('Missing elements:', missingElements);
return false; return false; // Gagal validasi
} }
return true; return true; // Semua element ada
} }
// ==================== UPDATE AUTH BUTTON ==================== /* ==========================================
UPDATE AUTH BUTTON - Toggle LOGIN/LOGOUT
==========================================
Cek dari localStorage & sessionStorage
========================================== */
function updateAuthButton() { function updateAuthButton() {
// Cek dari localStorage (authToken & username) atau sessionStorage // Cek apakah user sudah login
// 3 cara cek: authToken, username, atau loggedInUser
const authToken = localStorage.getItem('authToken'); const authToken = localStorage.getItem('authToken');
const username = localStorage.getItem('username'); const username = localStorage.getItem('username');
const loggedInUser = sessionStorage.getItem('loggedInUser'); const loggedInUser = sessionStorage.getItem('loggedInUser');
if (authToken || username || loggedInUser) { if (authToken || username || loggedInUser) {
// User is logged in - show LOGOUT button // User SUDAH LOGIN → show LOGOUT button
elements.authBtn.textContent = 'LOGOUT'; elements.authBtn.textContent = 'LOGOUT';
elements.authBtn.classList.add('logout-mode'); elements.authBtn.classList.add('logout-mode');
} else { } else {
// User is not logged in - show LOGIN button // User BELUM LOGIN → show LOGIN button
elements.authBtn.textContent = 'LOGIN'; elements.authBtn.textContent = 'LOGIN';
elements.authBtn.classList.remove('logout-mode'); elements.authBtn.classList.remove('logout-mode');
} }
} }
// ==================== EVENT LISTENERS ==================== /* ==========================================
SETUP EVENT LISTENERS - Bind semua event
========================================== */
function setupEventListeners() { function setupEventListeners() {
// Logo click - reload page // Logo click → reload homepage
if (elements.logo) { if (elements.logo) {
elements.logo.addEventListener('click', handleLogoClick); elements.logo.addEventListener('click', handleLogoClick);
} }
// Auth button (Login/Logout) // Auth button → login/logout
if (elements.authBtn) { if (elements.authBtn) {
elements.authBtn.addEventListener('click', handleAuthClick); elements.authBtn.addEventListener('click', handleAuthClick);
} }
// Play button // Play button → go to game
if (elements.playBtn) { if (elements.playBtn) {
elements.playBtn.addEventListener('click', handlePlayClick); elements.playBtn.addEventListener('click', handlePlayClick);
} }
@ -113,7 +144,7 @@
elements.leaderboardBtn.addEventListener('click', handleLeaderboardClick); elements.leaderboardBtn.addEventListener('click', handleLeaderboardClick);
} }
// Keyboard shortcuts // Keyboard shortcuts (P, L, B)
document.addEventListener('keydown', handleKeyPress); document.addEventListener('keydown', handleKeyPress);
// Window events // Window events
@ -121,54 +152,64 @@
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
} }
// ==================== EVENT HANDLERS ==================== /* ==========================================
EVENT HANDLERS
========================================== */
function handleLogoClick(e) { function handleLogoClick(e) {
e.preventDefault(); e.preventDefault();
window.location.href = 'Homepage.html'; window.location.href = 'Homepage.html';
} }
/* ==========================================
HANDLE AUTH CLICK - Login atau Logout
========================================== */
function handleAuthClick(e) { function handleAuthClick(e) {
// Cek dari localStorage atau sessionStorage // Cek status login dari localStorage & sessionStorage
const authToken = localStorage.getItem('authToken'); const authToken = localStorage.getItem('authToken');
const username = localStorage.getItem('username'); const username = localStorage.getItem('username');
const loggedInUser = sessionStorage.getItem('loggedInUser'); const loggedInUser = sessionStorage.getItem('loggedInUser');
if (authToken || username || loggedInUser) { if (authToken || username || loggedInUser) {
// User is logged in - perform logout // User sudah login → LOGOUT
handleLogout(); handleLogout();
} else { } else {
// User is not logged in - go to login page // User belum login → ke halaman LOGIN
window.location.href = 'Login.html'; window.location.href = 'Login.html';
} }
} }
/* ==========================================
HANDLE LOGOUT - Logout Logic dengan PHP
========================================== */
async function handleLogout() { async function handleLogout() {
try { try {
// Panggil PHP untuk hapus session // Step 1: Panggil PHP untuk hapus session di server
const response = await fetch("http://localhost/Kelompok06_2048/Logout.php", { const response = await fetch("http://localhost/Kelompok06_2048/Logout.php", {
method: "POST" method: "POST"
}); });
const data = await response.json(); const data = await response.json();
// Hapus token & username dari localStorage // Step 2: Hapus token & username dari localStorage
localStorage.removeItem("authToken"); localStorage.removeItem("authToken");
localStorage.removeItem("username"); localStorage.removeItem("username");
// Hapus juga dari sessionStorage (jika ada) // Step 3: Hapus juga dari sessionStorage
sessionStorage.removeItem('loggedInUser'); sessionStorage.removeItem('loggedInUser');
sessionStorage.removeItem('showTutorial'); sessionStorage.removeItem('showTutorial');
// Show success modal // Step 4: Show success modal
if (elements.logoutOverlay) { if (elements.logoutOverlay) {
elements.logoutOverlay.style.display = 'flex'; elements.logoutOverlay.style.display = 'flex';
// Auto close after 2 seconds and redirect to homepage // Auto close setelah 2 detik & redirect
setTimeout(() => { setTimeout(() => {
elements.logoutOverlay.style.display = 'none'; elements.logoutOverlay.style.display = 'none';
window.location.href = "Homepage.html"; window.location.href = "Homepage.html";
}, 2000); }, 2000);
} }
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
@ -182,7 +223,7 @@
if (elements.logoutFailedOverlay) { if (elements.logoutFailedOverlay) {
elements.logoutFailedOverlay.style.display = 'flex'; elements.logoutFailedOverlay.style.display = 'flex';
// Auto close after 2.5 seconds and redirect anyway // Auto close setelah 2.5 detik
setTimeout(() => { setTimeout(() => {
elements.logoutFailedOverlay.style.display = 'none'; elements.logoutFailedOverlay.style.display = 'none';
window.location.href = "Homepage.html"; window.location.href = "Homepage.html";
@ -192,31 +233,34 @@
} }
function handlePlayClick(e) { function handlePlayClick(e) {
// Allow default behavior (navigate to 2048.html) // Allow default behavior (navigate ke 2048.html)
console.log('Starting game...'); console.log('Starting game...');
} }
function handleLeaderboardClick(e) { function handleLeaderboardClick(e) {
// Allow default behavior (navigate to leaderboard.html) // Allow default behavior (navigate ke leaderboard.html)
console.log('Opening leaderboard...'); console.log('Opening leaderboard...');
} }
/* ==========================================
KEYBOARD SHORTCUTS
========================================== */
function handleKeyPress(e) { function handleKeyPress(e) {
// Press 'P' to play // Press 'P' → Play game
if (e.key === 'p' || e.key === 'P') { if (e.key === 'p' || e.key === 'P') {
if (elements.playBtn) { if (elements.playBtn) {
elements.playBtn.click(); elements.playBtn.click();
} }
} }
// Press 'L' for login/logout // Press 'L' → Login/Logout
if (e.key === 'l' || e.key === 'L') { if (e.key === 'l' || e.key === 'L') {
if (elements.authBtn) { if (elements.authBtn) {
elements.authBtn.click(); elements.authBtn.click();
} }
} }
// Press 'B' for leaderboard // Press 'B' → Leaderboard (Board)
if (e.key === 'b' || e.key === 'B') { if (e.key === 'b' || e.key === 'B') {
if (elements.leaderboardBtn) { if (elements.leaderboardBtn) {
elements.leaderboardBtn.click(); elements.leaderboardBtn.click();
@ -225,7 +269,7 @@
} }
function handleResize() { function handleResize() {
// Handle responsive behavior if needed // Handle responsive behavior (mobile, tablet, desktop)
const width = window.innerWidth; const width = window.innerWidth;
if (width < 768) { if (width < 768) {
@ -238,11 +282,13 @@
} }
function handleBeforeUnload(e) { function handleBeforeUnload(e) {
// Cleanup before page unload // Cleanup sebelum page unload
console.log('Page unloading...'); console.log('Page unloading...');
} }
// ==================== SMOOTH SCROLL ==================== /* ==========================================
SMOOTH SCROLL - Untuk anchor links (#)
========================================== */
function initSmoothScroll() { function initSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => { document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) { anchor.addEventListener('click', function(e) {
@ -262,7 +308,9 @@
}); });
} }
// ==================== UTILITY FUNCTIONS ==================== /* ==========================================
UTILITY FUNCTIONS
========================================== */
function checkBrowserSupport() { function checkBrowserSupport() {
const features = { const features = {
localStorage: typeof(Storage) !== 'undefined', localStorage: typeof(Storage) !== 'undefined',
@ -278,7 +326,9 @@
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
} }
// ==================== PUBLIC API ==================== /* ==========================================
PUBLIC API - Fungsi yang bisa dipanggil dari luar
========================================== */
window.Homepage = { window.Homepage = {
init: initAll, init: initAll,
isMobile: isMobile, isMobile: isMobile,
@ -286,9 +336,11 @@
updateAuthButton: updateAuthButton updateAuthButton: updateAuthButton
}; };
// ==================== CLEANUP ==================== /* ==========================================
CLEANUP - Remove semua event listener
========================================== */
window.cleanupHomepage = function() { window.cleanupHomepage = function() {
// Remove event listeners // Remove event listeners (untuk prevent memory leak)
if (elements.logo) { if (elements.logo) {
elements.logo.removeEventListener('click', handleLogoClick); elements.logo.removeEventListener('click', handleLogoClick);
} }
@ -312,7 +364,7 @@
console.log('Homepage cleaned up'); console.log('Homepage cleaned up');
}; };
// Start initialization // Start initialization saat script load
init(); init();
})(); })(); // IIFE - langsung execute

View File

@ -1,81 +1,150 @@
// Particle System /* ==========================================
const particlesContainer = document.getElementById('particles'); ANIMATION LOGIN - PARTICLE BACKGROUND SYSTEM
const particleCount = 150; ==========================================
const particles = []; fungsi:
- Class Particle: Objek partikel dengan posisi & kecepatan
- Even Distribution: Distribusi partikel merata (15x10 grid)
- Animate Loop: Pergerakan smooth dengan requestAnimationFrame
========================================== */
/* ==========================================
SETUP CONTAINER & CONFIG
========================================== */
const particlesContainer = document.getElementById('particles');
const particleCount = 150; // Total partikel yang dibuat
const particles = []; // Array untuk simpan semua partikel
/* ==========================================
CLASS PARTICLE - Blueprint untuk setiap partikel
==========================================
Properties:
- x, y: Posisi partikel
- vx, vy: Kecepatan horizontal & vertikal (velocity)
- size: Ukuran partikel (2-5px)
- color: Warna neon random
========================================== */
class Particle { class Particle {
constructor() { constructor() {
// Buat element DOM untuk partikel
this.element = document.createElement('div'); this.element = document.createElement('div');
this.element.className = 'particle'; this.element.className = 'particle';
this.reset(); this.reset(); // Initialize posisi & properti
particlesContainer.appendChild(this.element); particlesContainer.appendChild(this.element);
} }
/* ==========================================
RESET - Set/reset properti partikel
========================================== */
reset() { reset() {
// Posisi random di seluruh layar
this.x = Math.random() * window.innerWidth; this.x = Math.random() * window.innerWidth;
this.y = Math.random() * window.innerHeight; this.y = Math.random() * window.innerHeight;
// Kecepatan random (-0.6 sampai +0.6 pixel/frame)
this.vx = (Math.random() - 0.5) * 1.2; this.vx = (Math.random() - 0.5) * 1.2;
this.vy = (Math.random() - 0.5) * 1.2; this.vy = (Math.random() - 0.5) * 1.2;
// Ukuran random (2-5px)
this.size = Math.random() * 3 + 2; this.size = Math.random() * 3 + 2;
// Array warna neon untuk partikel
const colors = [ const colors = [
'#00d9ff', '#ff00ff', '#00ffff', '#00d9ff', // Cyan
'#ff0080', '#9d00ff', '#00ff88' '#ff00ff', // Magenta
'#00ffff', // Cyan bright
'#ff0080', // Pink
'#9d00ff', // Purple
'#00ff88' // Green
]; ];
// Pilih warna random dari array
const color = colors[Math.floor(Math.random() * colors.length)]; const color = colors[Math.floor(Math.random() * colors.length)];
this.element.style.background = color; this.element.style.background = color;
this.element.style.boxShadow = `0 0 15px ${color}`; this.element.style.boxShadow = `0 0 15px ${color}`; // Glow effect
this.element.style.width = `${this.size}px`; this.element.style.width = `${this.size}px`;
this.element.style.height = `${this.size}px`; this.element.style.height = `${this.size}px`;
this.updatePosition(); this.updatePosition(); // Update posisi di DOM
} }
/* ==========================================
UPDATE POSITION - Sync posisi ke DOM
========================================== */
updatePosition() { updatePosition() {
this.element.style.left = `${this.x}px`; this.element.style.left = `${this.x}px`;
this.element.style.top = `${this.y}px`; this.element.style.top = `${this.y}px`;
} }
/* ==========================================
MOVE - Update posisi berdasarkan velocity
==========================================
Fitur: Wrap-around screen (partikel keluar = muncul sisi lain)
========================================== */
move() { move() {
// Update posisi berdasarkan kecepatan
this.x += this.vx; this.x += this.vx;
this.y += this.vy; this.y += this.vy;
// ♻️ WRAP-AROUND LOGIC
// Kalau keluar dari kiri → muncul dari kanan
if (this.x < -10) this.x = window.innerWidth + 10; if (this.x < -10) this.x = window.innerWidth + 10;
// Kalau keluar dari kanan → muncul dari kiri
if (this.x > window.innerWidth + 10) this.x = -10; if (this.x > window.innerWidth + 10) this.x = -10;
// Kalau keluar dari atas → muncul dari bawah
if (this.y < -10) this.y = window.innerHeight + 10; if (this.y < -10) this.y = window.innerHeight + 10;
// Kalau keluar dari bawah → muncul dari atas
if (this.y > window.innerHeight + 10) this.y = -10; if (this.y > window.innerHeight + 10) this.y = -10;
this.updatePosition(); this.updatePosition(); // Sync ke DOM
} }
} }
// Create particles with even distribution /* ==========================================
const cols = 15; EVEN DISTRIBUTION - Distribusi Partikel Merata
const rows = 10; ==========================================
let particleIndex = 0; Konsep: Grid 15 kolom x 10 baris (total 150 partikel)
Biar nggak menumpuk di satu area
========================================== */
const cols = 15; // Jumlah kolom
const rows = 10; // Jumlah baris
let particleIndex = 0; // Counter partikel yang sudah dibuat
// Loop baris
for (let i = 0; i < rows; i++) { for (let i = 0; i < rows; i++) {
// Loop kolom
for (let j = 0; j < cols; j++) { for (let j = 0; j < cols; j++) {
// Guard: stop kalau sudah 150 partikel
if (particleIndex >= particleCount) break; if (particleIndex >= particleCount) break;
const particle = new Particle(); const particle = new Particle();
// Even distribution + slight random offset // ⚙️ DISTRIBUSI MERATA + RANDOM OFFSET
// Formula: (index / total) × lebar/tinggi layar
particle.x = (j / cols) * window.innerWidth + (Math.random() - 0.5) * 100; particle.x = (j / cols) * window.innerWidth + (Math.random() - 0.5) * 100;
particle.y = (i / rows) * window.innerHeight + (Math.random() - 0.5) * 100; particle.y = (i / rows) * window.innerHeight + (Math.random() - 0.5) * 100;
particle.updatePosition(); particle.updatePosition();
particles.push(particle); particles.push(particle); // Simpan ke array
particleIndex++; particleIndex++;
} }
if (particleIndex >= particleCount) break; if (particleIndex >= particleCount) break;
} }
// Animate /* ==========================================
ANIMATE - Main Animation Loop
==========================================
Menggunakan requestAnimationFrame untuk performa optimal
(60 FPS, smooth, GPU-accelerated)
========================================== */
function animate() { function animate() {
// Loop semua partikel dan gerakkan
particles.forEach(p => p.move()); particles.forEach(p => p.move());
// Request next frame (recursive call)
// Browser otomatis sync dengan refresh rate monitor
requestAnimationFrame(animate); requestAnimationFrame(animate);
} }
// Start animation loop
animate(); animate();