src/index.js 9.7 K raw
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)