/** * Leaderboard UI * Modal display and initials entry interface * Following arcade conventions with vintage aesthetic */ export class LeaderboardUI { constructor(leaderboardService) { this.service = leaderboardService; this.modal = null; this.initialsModal = null; this.currentSlot = 0; this.initials = ['A', 'A', 'A']; this.onInitialsSubmit = null; this.canSubmitInitials = false; // Prevent accidental immediate submission } /** * Show the leaderboard modal */ show() { // Remove existing modal if any this.hide(); const data = this.service.getFormattedLeaderboard(); const playerStats = this.service.getPlayerStats(); // Create modal HTML this.modal = document.createElement('div'); this.modal.className = 'leaderboard-overlay'; this.modal.innerHTML = `

High Scores

${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
${playerStats.highestLevel > 1 ? `
Your Best: Level ${playerStats.highestLevel}
Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)
Longest Streak: ${playerStats.longestStreak}
` : ''}
`; document.body.appendChild(this.modal); // Animate in requestAnimationFrame(() => { this.modal.classList.add('visible'); }); // Add event listeners this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide()); // Prevent clicks inside modal content from closing this.modal.querySelector('.leaderboard-modal').addEventListener('click', (e) => { e.stopPropagation(); }); // Close on backdrop click this.modal.addEventListener('click', (e) => { if (e.target === this.modal) { this.hide(); } }); // ESC key to close this.escHandler = (e) => { if (e.key === 'Escape') { this.hide(); } }; document.addEventListener('keydown', this.escHandler); } /** * Generate HTML for leaderboard entries */ generateLeaderboardHTML(entries, playerInitials) { if (entries.length === 0) { return `

No high scores yet!

Be the first to reach Level 2!

`; } return entries.map(entry => { const rankClass = this.getRankClass(entry.rank); const isPlayer = entry.initials === playerInitials; const playerClass = isPlayer ? 'player-entry' : ''; return `
#${entry.rank} ${entry.initials} Level ${entry.level}
`; }).join(''); } /** * Get CSS class for rank-based styling */ getRankClass(rank) { if (rank === 1) return 'rank-gold'; if (rank === 2 || rank === 3) return 'rank-silver'; return 'rank-standard'; } /** * Hide the leaderboard modal */ hide() { if (this.modal) { this.modal.classList.remove('visible'); setTimeout(() => { if (this.modal && this.modal.parentNode) { this.modal.parentNode.removeChild(this.modal); } this.modal = null; }, 300); } if (this.escHandler) { document.removeEventListener('keydown', this.escHandler); this.escHandler = null; } } /** * Show initials entry screen for new high score */ showInitialsEntry(level, round, rank, onSubmit) { // Store callback this.onInitialsSubmit = onSubmit; // Reset initials state this.currentSlot = 0; this.canSubmitInitials = false; // Disable submission until user has had time to interact // Get existing player initials if available const profile = this.service.getPlayerProfile(); if (profile && profile.initials) { this.initials = profile.initials.split(''); } else { this.initials = ['A', 'A', 'A']; } // Remove existing modal this.hideInitialsEntry(); // Create modal HTML this.initialsModal = document.createElement('div'); this.initialsModal.className = 'leaderboard-overlay initials-overlay'; this.initialsModal.innerHTML = `

New High Score

You reached Level ${level}
${this.getRankText(rank)}

Enter or update your initials:

Type your 3-letter initials directly

or use arcade controls
${this.initials.map((letter, index) => `
${letter}
`).join('')}

Use arrow keys ↑↓ to change letters

Press Tab or ←→ to move between slots

Press Enter to submit

`; document.body.appendChild(this.initialsModal); // Animate in requestAnimationFrame(() => { this.initialsModal.classList.add('visible'); }); // Add event listeners with a delay to prevent Enter key from passage submission // from immediately triggering the modal's submit handler setTimeout(() => { this.setupInitialsEventListeners(); // Focus the text input for easier typing const textInput = this.initialsModal.querySelector('#initials-text-input'); if (textInput) { textInput.focus(); textInput.select(); // Select all text for easy overwriting } // Enable submission after a longer delay to ensure user has time to interact setTimeout(() => { this.canSubmitInitials = true; console.log('🔓 Initials submission enabled - user can now submit'); }, 300); }, 100); } /** * Get rank description text */ getRankText(rank) { const ordinal = this.getOrdinal(rank); if (rank === 1) return `${ordinal} place - Top Score`; if (rank === 2) return `${ordinal} place`; if (rank === 3) return `${ordinal} place`; return `${ordinal} place on the leaderboard`; } /** * Get ordinal suffix for rank (1st, 2nd, 3rd, etc.) */ getOrdinal(n) { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); } /** * Setup event listeners for initials entry */ setupInitialsEventListeners() { // Text input field const textInput = this.initialsModal.querySelector('#initials-text-input'); textInput.addEventListener('input', (e) => { const value = e.target.value.toUpperCase().slice(0, 3); e.target.value = value; // Update arcade slots to match text input this.updateInitialsFromText(value); }); // Arrow buttons this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => { button.addEventListener('click', (e) => { const slot = parseInt(e.target.dataset.slot); const direction = e.target.dataset.direction; this.changeInitialLetter(slot, direction === 'up' ? 1 : -1); }); }); // Slot clicking to select this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => { slot.addEventListener('click', (e) => { if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) { const slotIndex = parseInt(slot.dataset.slot); this.selectSlot(slotIndex); } }); }); // Submit button this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => { this.submitInitials(); }); // Keyboard controls this.initialsKeyHandler = (e) => { // If focus is on text input, handle differently if (e.target.id === 'initials-text-input') { switch(e.key) { case 'Enter': e.preventDefault(); this.submitInitials(); break; case 'Escape': e.preventDefault(); this.hideInitialsEntry(); break; } return; } // Arcade controls when not focused on text input switch(e.key) { case 'ArrowUp': e.preventDefault(); this.changeInitialLetter(this.currentSlot, 1); break; case 'ArrowDown': e.preventDefault(); this.changeInitialLetter(this.currentSlot, -1); break; case 'ArrowLeft': e.preventDefault(); this.selectSlot(Math.max(0, this.currentSlot - 1)); break; case 'ArrowRight': case 'Tab': e.preventDefault(); this.selectSlot(Math.min(2, this.currentSlot + 1)); break; case 'Enter': e.preventDefault(); this.submitInitials(); break; case 'Escape': e.preventDefault(); this.hideInitialsEntry(); break; } }; document.addEventListener('keydown', this.initialsKeyHandler); // Prevent modal close on backdrop click for initials entry this.initialsModal.addEventListener('click', (e) => { e.stopPropagation(); }); } /** * Change letter in current slot */ changeInitialLetter(slot, delta) { const currentChar = this.initials[slot].charCodeAt(0); let newChar = currentChar + delta; // Wrap around A-Z if (newChar > 90) newChar = 65; // After Z, go to A if (newChar < 65) newChar = 90; // Before A, go to Z this.initials[slot] = String.fromCharCode(newChar); this.updateInitialsDisplay(); this.updateTextFromInitials(); } /** * Select a specific slot */ selectSlot(slot) { this.currentSlot = slot; this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => { el.classList.toggle('active', index === slot); }); } /** * Update arcade slots from text input */ updateInitialsFromText(text) { // Pad with 'A' if less than 3 characters const paddedText = text.padEnd(3, 'A'); this.initials = paddedText.split(''); this.updateInitialsDisplay(); } /** * Update text input from arcade slots */ updateTextFromInitials() { const textInput = this.initialsModal.querySelector('#initials-text-input'); if (textInput) { textInput.value = this.initials.join(''); } } /** * Update the visual display of initials */ updateInitialsDisplay() { this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => { slot.querySelector('.slot-letter').textContent = this.initials[index]; }); } /** * Submit initials and save to leaderboard */ submitInitials() { // Prevent accidental immediate submission if (!this.canSubmitInitials) { console.log('⏸️ Initials submission blocked - too soon after modal opened'); return; } const initialsString = this.initials.join(''); // Save to player profile const profile = this.service.getPlayerProfile(); profile.initials = initialsString; profile.hasEnteredInitials = true; this.service.savePlayerProfile(profile); // Call the callback if (this.onInitialsSubmit) { this.onInitialsSubmit(initialsString); } // Hide modal this.hideInitialsEntry(); // Show success message briefly, then show leaderboard this.showSuccessMessage(() => { this.show(); }); } /** * Hide initials entry modal */ hideInitialsEntry() { if (this.initialsModal) { this.initialsModal.classList.remove('visible'); setTimeout(() => { if (this.initialsModal && this.initialsModal.parentNode) { this.initialsModal.parentNode.removeChild(this.initialsModal); } this.initialsModal = null; }, 300); } if (this.initialsKeyHandler) { document.removeEventListener('keydown', this.initialsKeyHandler); this.initialsKeyHandler = null; } } /** * Show success message after submitting initials */ showSuccessMessage(onComplete) { const successDiv = document.createElement('div'); successDiv.className = 'leaderboard-overlay visible'; successDiv.innerHTML = `

Score Saved

Your initials have been added to the leaderboard

`; document.body.appendChild(successDiv); setTimeout(() => { successDiv.classList.remove('visible'); setTimeout(() => { if (successDiv.parentNode) { successDiv.parentNode.removeChild(successDiv); } if (onComplete) { onComplete(); } }, 300); }, 1500); } /** * Show notification toast for milestone achievement */ showMilestoneNotification(level) { const toast = document.createElement('div'); toast.className = 'milestone-toast'; toast.innerHTML = `
Milestone Reached: Level ${level}
`; document.body.appendChild(toast); // Animate in requestAnimationFrame(() => { toast.classList.add('visible'); }); // Auto-hide after 3 seconds setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 3000); } }