| 1 | import DinoGame from './game/DinoGame.js' |
| 2 | import { toggleMute } from './sounds.js' |
| 3 | |
| 4 | const game = new DinoGame(600, 150, document.getElementById('game-container')) |
| 5 | |
| 6 | const keycodes = { |
| 7 | JUMP: { 38: 1, 32: 1 }, |
| 8 | DUCK: { 40: 1 }, |
| 9 | } |
| 10 | |
| 11 | const container = document.getElementById('game-container') |
| 12 | const overlay = document.createElement('div') |
| 13 | overlay.id = 'score-overlay' |
| 14 | overlay.style.display = 'none' |
| 15 | container.appendChild(overlay) |
| 16 | |
| 17 | overlay.innerHTML = ` |
| 18 | <div id="score-modal" role="dialog" aria-labelledby="modal-title" aria-modal="true"> |
| 19 | <h2 id="modal-title">GAME OVER</h2> |
| 20 | <p id="final-score-display" aria-live="polite"></p> |
| 21 | <div id="submit-section"> |
| 22 | <input |
| 23 | type="text" |
| 24 | id="player-initials" |
| 25 | placeholder="AAA" |
| 26 | maxlength="3" |
| 27 | autocomplete="off" |
| 28 | autocapitalize="characters" |
| 29 | aria-label="Enter your initials (3 letters)" |
| 30 | /> |
| 31 | <button id="submit-btn" aria-label="Submit your score">SUBMIT</button> |
| 32 | </div> |
| 33 | <div id="leaderboard-section" aria-live="polite"> |
| 34 | <div id="leaderboard-list"></div> |
| 35 | </div> |
| 36 | <div id="modal-footer"> |
| 37 | <button id="action-btn" aria-label="Play again">PLAY AGAIN</button> |
| 38 | </div> |
| 39 | </div> |
| 40 | ` |
| 41 | |
| 42 | const modalTitle = document.getElementById('modal-title') |
| 43 | const finalScoreDisplay = document.getElementById('final-score-display') |
| 44 | const initialsInput = document.getElementById('player-initials') |
| 45 | const submitBtn = document.getElementById('submit-btn') |
| 46 | const submitSection = document.getElementById('submit-section') |
| 47 | const leaderboardSection = document.getElementById('leaderboard-section') |
| 48 | const leaderboardList = document.getElementById('leaderboard-list') |
| 49 | const actionBtn = document.getElementById('action-btn') |
| 50 | |
| 51 | // Focus trap elements |
| 52 | let focusableElements |
| 53 | let firstFocusable |
| 54 | let lastFocusable |
| 55 | |
| 56 | function updateFocusTrap() { |
| 57 | focusableElements = overlay.querySelectorAll('button:not(:disabled), input:not(:disabled)') |
| 58 | firstFocusable = focusableElements[0] |
| 59 | lastFocusable = focusableElements[focusableElements.length - 1] |
| 60 | } |
| 61 | |
| 62 | // Handle Tab key for focus trap |
| 63 | overlay.addEventListener('keydown', (e) => { |
| 64 | if (e.key !== 'Tab' || !overlayState.visible) return |
| 65 | |
| 66 | if (e.shiftKey) { |
| 67 | if (document.activeElement === firstFocusable) { |
| 68 | e.preventDefault() |
| 69 | lastFocusable.focus() |
| 70 | } |
| 71 | } else { |
| 72 | if (document.activeElement === lastFocusable) { |
| 73 | e.preventDefault() |
| 74 | firstFocusable.focus() |
| 75 | } |
| 76 | } |
| 77 | }) |
| 78 | |
| 79 | // Touch to dismiss overlay (tap background) - only closes modal, doesn't restart |
| 80 | overlay.addEventListener('click', (e) => { |
| 81 | if (e.target === overlay && overlayState.visible) { |
| 82 | hideOverlay() |
| 83 | } |
| 84 | }) |
| 85 | |
| 86 | // Overlay state management |
| 87 | const overlayState = { |
| 88 | visible: false, |
| 89 | submitted: false, |
| 90 | submitting: false, |
| 91 | submittedInitials: '', |
| 92 | currentScore: 0, |
| 93 | currentToken: null, |
| 94 | } |
| 95 | |
| 96 | function escapeHtml(text) { |
| 97 | const div = document.createElement('div') |
| 98 | div.textContent = text |
| 99 | return div.innerHTML |
| 100 | } |
| 101 | |
| 102 | function isTopTenScore(score) { |
| 103 | if (!leaderboardCache.data || !leaderboardCache.data.scores) return true |
| 104 | const scores = leaderboardCache.data.scores |
| 105 | if (scores.length < 10) return true |
| 106 | const lowestTopScore = scores[scores.length - 1].score |
| 107 | return score > lowestTopScore |
| 108 | } |
| 109 | |
| 110 | function showOverlay(score, tokenPromise) { |
| 111 | overlayState.visible = true |
| 112 | overlayState.submitted = false |
| 113 | overlayState.submitting = false |
| 114 | overlayState.submittedInitials = '' |
| 115 | overlayState.currentScore = score |
| 116 | overlayState.currentToken = tokenPromise |
| 117 | |
| 118 | modalTitle.textContent = 'GAME OVER' |
| 119 | finalScoreDisplay.textContent = `SCORE: ${score}` |
| 120 | initialsInput.value = '' |
| 121 | initialsInput.disabled = false |
| 122 | submitBtn.disabled = false |
| 123 | submitBtn.textContent = 'SUBMIT' |
| 124 | overlay.style.display = '' |
| 125 | |
| 126 | const qualifies = isTopTenScore(score) |
| 127 | submitSection.style.display = qualifies ? '' : 'none' |
| 128 | |
| 129 | updateFocusTrap() |
| 130 | if (qualifies) { |
| 131 | initialsInput.focus() |
| 132 | } else { |
| 133 | actionBtn.focus() |
| 134 | } |
| 135 | loadLeaderboard(false) // Use cache if available |
| 136 | } |
| 137 | |
| 138 | function hideOverlay() { |
| 139 | overlayState.visible = false |
| 140 | overlayState.currentScore = 0 |
| 141 | overlayState.currentToken = null |
| 142 | overlay.style.display = 'none' |
| 143 | } |
| 144 | |
| 145 | function formatInitials(value) { |
| 146 | return value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3) |
| 147 | } |
| 148 | |
| 149 | initialsInput.addEventListener('input', (e) => { |
| 150 | e.target.value = formatInitials(e.target.value) |
| 151 | }) |
| 152 | |
| 153 | async function submitScore() { |
| 154 | if (overlayState.submitted || overlayState.submitting) return |
| 155 | |
| 156 | const initials = initialsInput.value.trim().toUpperCase() || 'AAA' |
| 157 | overlayState.submitting = true |
| 158 | submitBtn.disabled = true |
| 159 | |
| 160 | try { |
| 161 | const token = await overlayState.currentToken |
| 162 | if (!token) { |
| 163 | modalTitle.textContent = 'SESSION ERROR' |
| 164 | overlayState.submitting = false |
| 165 | submitBtn.disabled = false |
| 166 | submitBtn.textContent = 'SUBMIT' |
| 167 | return |
| 168 | } |
| 169 | |
| 170 | const res = await fetch('/api/scores', { |
| 171 | method: 'POST', |
| 172 | headers: { 'Content-Type': 'application/json' }, |
| 173 | body: JSON.stringify({ |
| 174 | playerName: initials, |
| 175 | score: overlayState.currentScore, |
| 176 | token, |
| 177 | }), |
| 178 | }) |
| 179 | |
| 180 | if (res.ok) { |
| 181 | overlayState.submitted = true |
| 182 | overlayState.submitting = false |
| 183 | overlayState.submittedInitials = initials |
| 184 | initialsInput.disabled = true |
| 185 | submitSection.style.display = 'none' |
| 186 | updateFocusTrap() // Update after hiding submit section |
| 187 | loadLeaderboard(true) // Force refresh after submit |
| 188 | } else { |
| 189 | const data = await res.json() |
| 190 | modalTitle.textContent = data.error || 'FAILED' |
| 191 | overlayState.submitting = false |
| 192 | submitBtn.disabled = false |
| 193 | submitBtn.textContent = 'SUBMIT' |
| 194 | } |
| 195 | } catch { |
| 196 | modalTitle.textContent = 'NETWORK ERROR' |
| 197 | overlayState.submitting = false |
| 198 | submitBtn.disabled = false |
| 199 | submitBtn.textContent = 'SUBMIT' |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | submitBtn.addEventListener('click', submitScore) |
| 204 | actionBtn.addEventListener('click', () => { |
| 205 | hideOverlay() |
| 206 | game.onInput('jump') |
| 207 | }) |
| 208 | initialsInput.addEventListener('keydown', (e) => { |
| 209 | if (e.key === 'Enter') { |
| 210 | submitScore() |
| 211 | } |
| 212 | }) |
| 213 | // Prevent spacebar from natively activating buttons (which would restart the game) |
| 214 | actionBtn.addEventListener('keydown', (e) => { |
| 215 | if (e.key === ' ') { |
| 216 | e.preventDefault() |
| 217 | } |
| 218 | }) |
| 219 | |
| 220 | // Leaderboard caching |
| 221 | const leaderboardCache = { |
| 222 | data: null, |
| 223 | timestamp: 0, |
| 224 | TTL: 10000, // 10 seconds |
| 225 | } |
| 226 | |
| 227 | async function loadLeaderboard(forceRefresh = false) { |
| 228 | const now = Date.now() |
| 229 | |
| 230 | // Use cache if valid and not forcing refresh |
| 231 | if ( |
| 232 | !forceRefresh && |
| 233 | leaderboardCache.data && |
| 234 | now - leaderboardCache.timestamp < leaderboardCache.TTL |
| 235 | ) { |
| 236 | renderLeaderboard(leaderboardCache.data) |
| 237 | return |
| 238 | } |
| 239 | |
| 240 | leaderboardList.innerHTML = '<p class="loading">LOADING...</p>' |
| 241 | |
| 242 | try { |
| 243 | const res = await fetch('/api/scores') |
| 244 | if (!res.ok) { |
| 245 | leaderboardList.innerHTML = '<p class="error">FAILED TO LOAD</p>' |
| 246 | return |
| 247 | } |
| 248 | const data = await res.json() |
| 249 | |
| 250 | leaderboardCache.data = data |
| 251 | leaderboardCache.timestamp = now |
| 252 | |
| 253 | renderLeaderboard(data) |
| 254 | } catch { |
| 255 | leaderboardList.innerHTML = '<p class="error">FAILED TO LOAD</p>' |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | function renderLeaderboard(data) { |
| 260 | if (data.scores && data.scores.length > 0) { |
| 261 | leaderboardList.innerHTML = ` |
| 262 | <table class="leaderboard-table"> |
| 263 | <thead> |
| 264 | <tr> |
| 265 | <th>#</th> |
| 266 | <th>NAME</th> |
| 267 | <th>SCORE</th> |
| 268 | </tr> |
| 269 | </thead> |
| 270 | <tbody> |
| 271 | ${data.scores |
| 272 | .map( |
| 273 | (s, i) => ` |
| 274 | <tr class="${s.score === overlayState.currentScore && s.player_name === overlayState.submittedInitials ? 'highlight' : ''}"> |
| 275 | <td>${i + 1}</td> |
| 276 | <td>${escapeHtml(s.player_name)}</td> |
| 277 | <td>${s.score}</td> |
| 278 | </tr> |
| 279 | ` |
| 280 | ) |
| 281 | .join('')} |
| 282 | </tbody> |
| 283 | </table> |
| 284 | ` |
| 285 | } else { |
| 286 | leaderboardList.innerHTML = '<p class="empty">NO SCORES YET</p>' |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | const cheatsheet = document.getElementById('cheatsheet') |
| 291 | let cheatsheetTimer = null |
| 292 | |
| 293 | game.onGameStart = () => { |
| 294 | cheatsheet.classList.remove('hidden') |
| 295 | touchZones.classList.remove('hidden') |
| 296 | clearTimeout(cheatsheetTimer) |
| 297 | cheatsheetTimer = setTimeout(() => { |
| 298 | cheatsheet.classList.add('hidden') |
| 299 | touchZones.classList.add('hidden') |
| 300 | }, 2000) |
| 301 | } |
| 302 | |
| 303 | game.onGameOver = (score, tokenPromise) => { |
| 304 | setTimeout(() => showOverlay(score, tokenPromise), 300) |
| 305 | } |
| 306 | |
| 307 | document.addEventListener('keydown', (e) => { |
| 308 | if (!overlayState.visible) { |
| 309 | if (keycodes.JUMP[e.keyCode]) { |
| 310 | game.onInput('jump') |
| 311 | } else if (keycodes.DUCK[e.keyCode]) { |
| 312 | game.onInput('duck') |
| 313 | } |
| 314 | } else { |
| 315 | // Escape key closes modal only (must press Play Again to restart) |
| 316 | if (e.key === 'Escape') { |
| 317 | hideOverlay() |
| 318 | return |
| 319 | } |
| 320 | // Space/Up closes modal only if focus is outside the modal (must press Play Again to restart) |
| 321 | const modal = document.getElementById('score-modal') |
| 322 | if (!modal.contains(document.activeElement) && keycodes.JUMP[e.keyCode]) { |
| 323 | hideOverlay() |
| 324 | } |
| 325 | } |
| 326 | }) |
| 327 | |
| 328 | document.addEventListener('keyup', (e) => { |
| 329 | if (!overlayState.visible && keycodes.DUCK[e.keyCode]) { |
| 330 | game.onInput('stop-duck') |
| 331 | } |
| 332 | }) |
| 333 | |
| 334 | const touchZones = document.getElementById('touch-zones') |
| 335 | const touchDuck = document.getElementById('touch-duck') |
| 336 | const touchJump = document.getElementById('touch-jump') |
| 337 | |
| 338 | touchDuck.addEventListener( |
| 339 | 'touchstart', |
| 340 | (e) => { |
| 341 | e.preventDefault() |
| 342 | if (!overlayState.visible) game.onInput('duck') |
| 343 | }, |
| 344 | { passive: false } |
| 345 | ) |
| 346 | |
| 347 | touchDuck.addEventListener('touchend', (e) => { |
| 348 | if (!overlayState.visible) game.onInput('stop-duck') |
| 349 | }) |
| 350 | |
| 351 | touchJump.addEventListener( |
| 352 | 'touchstart', |
| 353 | (e) => { |
| 354 | e.preventDefault() |
| 355 | if (!overlayState.visible) game.onInput('jump') |
| 356 | }, |
| 357 | { passive: false } |
| 358 | ) |
| 359 | |
| 360 | // Mute toggle with 'M' key |
| 361 | document.addEventListener('keydown', (e) => { |
| 362 | if (e.key === 'm' || e.key === 'M') { |
| 363 | toggleMute() |
| 364 | } |
| 365 | }) |
| 366 | |
| 367 | game.start().catch(console.error) |
| 368 | loadLeaderboard(false) |