const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const scoreEl = document.getElementById('score'); const heartsEl = document.getElementById('hearts'); const deathScreenEl = document.getElementById('deathScreen'); const finalScoreEl = document.getElementById('finalScore'); const leaderboardEl = document.getElementById('leaderboard'); const saveScoreFormEl = document.getElementById('saveScoreForm'); const playerNameEl = document.getElementById('playerName'); const restartBtnEl = document.getElementById('restartBtn'); const keys = new Set(); const gravity = 1900; const groundY = 420; const worldWidth = 6000; let audioCtx = null; function ensureAudioCtx() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); } function playSfx(type) { try { ensureAudioCtx(); const now = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); const presets = { jump: { f0: 420, f1: 680, dur: 0.1, wave: 'triangle', vol: 0.06 }, squish: { f0: 180, f1: 120, dur: 0.12, wave: 'square', vol: 0.07 }, duck: { f0: 240, f1: 180, dur: 0.06, wave: 'sine', vol: 0.05 }, ptero_spawn: { f0: 520, f1: 460, dur: 0.1, wave: 'sawtooth', vol: 0.05 }, meteor_spawn: { f0: 300, f1: 200, dur: 0.14, wave: 'sawtooth', vol: 0.06 }, meteor_land: { f0: 140, f1: 70, dur: 0.18, wave: 'square', vol: 0.08 }, hit: { f0: 260, f1: 140, dur: 0.1, wave: 'square', vol: 0.06 } }; const p = presets[type]; if (!p) return; osc.type = p.wave; osc.frequency.setValueAtTime(p.f0, now); osc.frequency.exponentialRampToValueAtTime(Math.max(40, p.f1), now + p.dur); gain.gain.setValueAtTime(p.vol, now); gain.gain.exponentialRampToValueAtTime(0.0001, now + p.dur); osc.start(now); osc.stop(now + p.dur); } catch (_) { // ignore audio errors in unsupported environments } } const waterGaps = [ { x: 700, width: 130 }, { x: 1300, width: 150 }, { x: 2100, width: 170 }, { x: 3000, width: 140 }, { x: 3900, width: 200 } ]; function createInitialState() { return { running: true, dead: false, deadReason: '', lastTs: performance.now(), score: 0, stepCarry: 0, cameraX: 0, hearts: 3, hitCooldown: 0, ants: [ { x: 1000, y: groundY - 20, width: 22, height: 20, vx: -65, alive: true }, { x: 1750, y: groundY - 20, width: 22, height: 20, vx: -70, alive: true }, { x: 2600, y: groundY - 20, width: 22, height: 20, vx: -80, alive: true }, { x: 3400, y: groundY - 20, width: 22, height: 20, vx: -75, alive: true } ], pterodactyls: [], pteroSpawnTimer: 0, meteors: [], meteorSpawnTimer: 0, floatingTexts: [], player: { x: 160, y: groundY - 76, width: 64, height: 76, standingHeight: 76, duckHeight: 48, vx: 0, vy: 0, speed: 300, jumpPower: 760, onGround: true, ducking: false } }; } let state = createInitialState(); let saveEligible = false; let scoreSavedThisRun = false; let saveInFlight = false; const saveScoreBtnEl = saveScoreFormEl.querySelector('button[type="submit"]'); function setSaveFormBusy(isBusy) { saveInFlight = isBusy; playerNameEl.disabled = isBusy; if (saveScoreBtnEl) saveScoreBtnEl.disabled = isBusy; } window.addEventListener('keydown', (e) => { if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) e.preventDefault(); keys.add(e.key); if (e.key === 'ArrowUp' && state.player.onGround && state.running) { state.player.vy = -state.player.jumpPower; state.player.onGround = false; playSfx('jump'); } }); window.addEventListener('keyup', (e) => keys.delete(e.key)); restartBtnEl.addEventListener('click', () => { state = createInitialState(); keys.clear(); deathScreenEl.classList.add('hidden'); saveScoreFormEl.classList.add('hidden'); saveEligible = false; scoreSavedThisRun = false; setSaveFormBusy(false); }); saveScoreFormEl.addEventListener('submit', async (e) => { e.preventDefault(); if (!saveEligible || saveInFlight || scoreSavedThisRun) return; const name = playerNameEl.value.trim() || 'Anonymous'; setSaveFormBusy(true); try { const res = await fetch('api/scores.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, score: state.score }) }); if (!res.ok) throw new Error('Score save failed'); saveEligible = false; scoreSavedThisRun = true; saveScoreFormEl.classList.add('hidden'); await populateLeaderboard(); } catch (err) { console.error(err); } finally { setSaveFormBusy(false); } }); function drawBackground() { ctx.fillStyle = '#8ddcff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#88d34f'; ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY); for (const gap of waterGaps) { const sx = gap.x - state.cameraX; if (sx + gap.width < 0 || sx > canvas.width) continue; ctx.fillStyle = '#2e8bcf'; ctx.fillRect(sx, groundY, gap.width, canvas.height - groundY); } } function drawTRex(p) { const sx = p.x - state.cameraX; ctx.fillStyle = '#35535f'; ctx.fillRect(sx, p.y, p.width, p.height); ctx.fillStyle = '#27404a'; ctx.fillRect(sx + p.width - 12, p.y + 14, 7, 7); ctx.fillRect(sx + 10, p.y + p.height - 8, 14, 8); ctx.fillRect(sx + p.width - 24, p.y + p.height - 8, 14, 8); } function drawAnts() { ctx.fillStyle = '#5a1e0d'; for (const ant of state.ants) { if (!ant.alive) continue; const sx = ant.x - state.cameraX; if (sx + ant.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, ant.y, ant.width, ant.height); ctx.fillRect(sx - 4, ant.y + 6, 4, 4); ctx.fillRect(sx + ant.width, ant.y + 6, 4, 4); } } function drawPterodactyls() { ctx.fillStyle = '#6e5a8a'; for (const p of state.pterodactyls) { const sx = p.x - state.cameraX; if (sx + p.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, p.y, p.width, p.height); ctx.fillRect(sx - 8, p.y + 6, 8, 4); ctx.fillRect(sx + p.width, p.y + 6, 8, 4); } } function drawMeteors() { ctx.fillStyle = '#6b4d2e'; for (const m of state.meteors) { const sx = m.x - state.cameraX; if (sx + m.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, m.y, m.width, m.height); } } function addFloatingText(x, y, text, color = '#ffe066') { state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 }); } function updateFloatingTexts(dt) { for (const t of state.floatingTexts) { t.ttl -= dt; t.y += t.vy * dt; } state.floatingTexts = state.floatingTexts.filter((t) => t.ttl > 0); } function drawFloatingTexts() { ctx.save(); ctx.font = 'bold 22px Arial'; ctx.textAlign = 'center'; for (const t of state.floatingTexts) { const alpha = Math.max(0, Math.min(1, t.ttl / 0.8)); ctx.globalAlpha = alpha; ctx.fillStyle = t.color; ctx.fillText(t.text, t.x - state.cameraX, t.y); } ctx.restore(); } const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; function updateHeartsUI() { const active = ''.repeat(state.hearts); const lost = ''.repeat(Math.max(0, 3 - state.hearts)); heartsEl.innerHTML = active + lost; } async function loadTopScores() { const res = await fetch('api/scores.php'); return res.ok ? res.json() : []; } async function populateLeaderboard() { const scores = await loadTopScores(); leaderboardEl.innerHTML = ''; scores.forEach((s) => { const li = document.createElement('li'); li.textContent = `${s.name} — ${s.score}`; leaderboardEl.appendChild(li); }); const qualifies = scores.length < 10 || state.score > (scores[scores.length - 1]?.score ?? -1); saveEligible = qualifies && !scoreSavedThisRun && !saveInFlight; saveScoreFormEl.classList.toggle('hidden', !saveEligible); } async function kill(reason) { if (state.dead) return; state.running = false; state.dead = true; state.deadReason = reason; finalScoreEl.textContent = `Final Score: ${state.score}`; await populateLeaderboard(); deathScreenEl.classList.remove('hidden'); } function applyHit(reason) { if (state.hitCooldown > 0 || state.dead) return; state.hearts -= 1; state.hitCooldown = 1; playSfx('hit'); if (state.hearts <= 0) kill(reason); } function isOverWater(player) { const centerX = player.x + player.width / 2; return waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width); } function updateAnts(dt) { const p = state.player; const prevBottom = p.y + p.height - p.vy * dt; for (const ant of state.ants) { if (!ant.alive) continue; ant.x += ant.vx * dt; if (ant.x < 200) ant.vx = Math.abs(ant.vx); if (ant.x > worldWidth - 200) ant.vx = -Math.abs(ant.vx); if (intersects(p, ant)) { const playerBottom = p.y + p.height; const stomped = p.vy > 0 && prevBottom <= ant.y + 4 && playerBottom >= ant.y; if (stomped) { ant.alive = false; p.vy = -380; state.score += 50; addFloatingText(ant.x + ant.width / 2, ant.y - 10, '+50', '#ffe066'); playSfx('squish'); } else { applyHit('A swarm of ants got you.'); } } } } const PTERODACTYL_LANES = [ // low lane: standing dino collides, ducking dino should pass underneath { y: groundY - 80, height: 28 } ]; function getPterodactylHitbox(flyer) { return { x: flyer.x + 8, y: flyer.y + 6, width: flyer.width - 16, height: flyer.height - 12 }; } function getPlayerHitboxForAirHazards(player) { const widthInset = player.ducking ? 8 : 6; const topInset = player.ducking ? 8 : 4; const bottomInset = 2; return { x: player.x + widthInset, y: player.y + topInset, width: player.width - widthInset * 2, height: player.height - topInset - bottomInset }; } function updatePterodactyls(dt) { if (state.score <= 100) return; state.pteroSpawnTimer -= dt; if (state.pteroSpawnTimer <= 0) { const lane = PTERODACTYL_LANES[Math.floor(Math.random() * PTERODACTYL_LANES.length)]; state.pterodactyls.push({ x: state.cameraX + canvas.width + 80, y: lane.y, width: 54, height: lane.height, vx: -280 }); playSfx('ptero_spawn'); state.pteroSpawnTimer = 2.2; } const p = state.player; const playerHitbox = getPlayerHitboxForAirHazards(p); for (const flyer of state.pterodactyls) { flyer.x += flyer.vx * dt; if (intersects(playerHitbox, getPterodactylHitbox(flyer))) applyHit('Pterodactyl collision. Duck next time!'); } state.pterodactyls = state.pterodactyls.filter((f) => f.x + f.width > state.cameraX - 150); } function updateMeteors(dt) { if (state.score <= 250) return; state.meteorSpawnTimer -= dt; if (state.meteorSpawnTimer <= 0) { const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160); state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420, landed: false }); playSfx('meteor_spawn'); state.meteorSpawnTimer = 1.5; } const p = state.player; for (const m of state.meteors) { m.y += m.vy * dt; if (!m.landed && m.y + m.height >= groundY) { m.landed = true; playSfx('meteor_land'); } if (intersects(p, m)) { applyHit('Meteor strike!'); m.y = canvas.height + 100; } } state.meteors = state.meteors.filter((m) => m.y < canvas.height + 80); } function updatePlayer(dt) { const p = state.player; let move = 0; if (keys.has('ArrowLeft')) move -= 1; if (keys.has('ArrowRight')) move += 1; const wasDucking = p.ducking; p.ducking = keys.has('ArrowDown') && p.onGround; if (!wasDucking && p.ducking) playSfx('duck'); const targetHeight = p.ducking ? p.duckHeight : p.standingHeight; if (targetHeight !== p.height) { p.y += p.height - targetHeight; p.height = targetHeight; } p.vx = move * p.speed; const oldX = p.x; p.x += p.vx * dt; p.x = Math.max(0, Math.min(worldWidth - p.width, p.x)); if (p.x > oldX) { state.stepCarry += p.x - oldX; while (state.stepCarry >= 8) { state.score += 1; state.stepCarry -= 8; } } p.vy += gravity * dt; p.y += p.vy * dt; if (p.y + p.height >= groundY) { p.y = groundY - p.height; p.vy = 0; p.onGround = true; if (isOverWater(p)) kill('SPLASH! Water is instant death.'); } else { p.onGround = false; } state.cameraX = Math.max(0, Math.min(worldWidth - canvas.width, p.x - 240)); } function tick(ts) { const dt = Math.min((ts - state.lastTs) / 1000, 0.05); state.lastTs = ts; if (state.running) { state.hitCooldown = Math.max(0, state.hitCooldown - dt); updatePlayer(dt); updateAnts(dt); updatePterodactyls(dt); updateMeteors(dt); updateFloatingTexts(dt); } ctx.clearRect(0, 0, canvas.width, canvas.height); drawBackground(); drawAnts(); drawPterodactyls(); drawMeteors(); drawFloatingTexts(); drawTRex(state.player); scoreEl.textContent = `Score: ${state.score}`; updateHeartsUI(); requestAnimationFrame(tick); } requestAnimationFrame((ts) => { state.lastTs = ts; updateHeartsUI(); requestAnimationFrame(tick); });