diff --git a/api/scores.php b/api/scores.php new file mode 100644 index 0000000..48df61f --- /dev/null +++ b/api/scores.php @@ -0,0 +1,63 @@ + ($b['score'] ?? 0) <=> ($a['score'] ?? 0)); + $scores = array_slice($scores, 0, 10); + file_put_contents($file, json_encode($scores, JSON_PRETTY_PRINT)); +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +if ($method === 'GET') { + $scores = loadScores($storageFile); + usort($scores, fn($a, $b) => ($b['score'] ?? 0) <=> ($a['score'] ?? 0)); + echo json_encode(array_slice($scores, 0, 10)); + exit; +} + +if ($method === 'POST') { + $payload = json_decode(file_get_contents('php://input') ?: '{}', true); + $name = trim((string)($payload['name'] ?? 'Anonymous')); + $score = (int)($payload['score'] ?? 0); + + if ($name === '') $name = 'Anonymous'; + $name = mb_substr($name, 0, 20); + if ($score < 0) $score = 0; + + $scores = loadScores($storageFile); + $scores[] = [ + 'name' => $name, + 'score' => $score, + 'date' => date('c') + ]; + + saveScores($storageFile, $scores); + + echo json_encode(['ok' => true]); + exit; +} + +http_response_code(405); +echo json_encode(['error' => 'Method Not Allowed']); diff --git a/data/scores.json b/data/scores.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/scores.json @@ -0,0 +1 @@ +[] diff --git a/js/game.js b/js/game.js index 066080f..79d1700 100644 --- a/js/game.js +++ b/js/game.js @@ -2,6 +2,12 @@ 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; @@ -16,62 +22,87 @@ const waterGaps = [ { x: 3900, width: 200 } ]; -const state = { - 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, - player: { - x: 160, - y: 0, - width: 64, - height: 76, - standingHeight: 76, - duckHeight: 48, - vx: 0, - vy: 0, - speed: 300, - jumpPower: 760, - onGround: true, - ducking: false - } -}; -state.player.y = groundY - state.player.height; +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, + 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; window.addEventListener('keydown', (e) => { - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - e.preventDefault(); - } + if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) e.preventDefault(); keys.add(e.key); - if (e.key === 'ArrowUp' && state.player.onGround) { + if (e.key === 'ArrowUp' && state.player.onGround && state.running) { state.player.vy = -state.player.jumpPower; state.player.onGround = false; } }); 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; +}); + +saveScoreFormEl.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!saveEligible) return; + const name = playerNameEl.value.trim() || 'Anonymous'; + + await fetch('api/scores.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, score: state.score }) + }); + + saveEligible = false; + saveScoreFormEl.classList.add('hidden'); + await populateLeaderboard(); +}); + 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); - // Water gaps for (const gap of waterGaps) { const sx = gap.x - state.cameraX; if (sx + gap.width < 0 || sx > canvas.width) continue; @@ -122,27 +153,47 @@ function drawMeteors() { } } -function intersects(a, b) { - return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; -} +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() { heartsEl.textContent = '❤'.repeat(state.hearts) + '🖤'.repeat(Math.max(0, 3 - state.hearts)); } -function kill(reason) { +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; + saveScoreFormEl.classList.toggle('hidden', !qualifies); +} + +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.0; - if (state.hearts <= 0) { - kill(reason); - } + state.hitCooldown = 1; + if (state.hearts <= 0) kill(reason); } function isOverWater(player) { @@ -156,7 +207,6 @@ function updateAnts(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); @@ -176,13 +226,12 @@ function updateAnts(dt) { function updatePterodactyls(dt) { if (state.score <= 100) return; - state.pteroSpawnTimer -= dt; + if (state.pteroSpawnTimer <= 0) { - const altitude = groundY - (state.player.standingHeight - 24); state.pterodactyls.push({ x: state.cameraX + canvas.width + 80, - y: altitude, + y: groundY - (state.player.standingHeight - 24), width: 54, height: 26, vx: -280 @@ -193,17 +242,15 @@ function updatePterodactyls(dt) { const p = state.player; for (const flyer of state.pterodactyls) { flyer.x += flyer.vx * dt; - if (intersects(p, flyer)) { - applyHit('Pterodactyl collision. Duck next time!'); - } + if (intersects(p, 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 }); @@ -254,10 +301,7 @@ function updatePlayer(dt) { p.y = groundY - p.height; p.vy = 0; p.onGround = true; - - if (isOverWater(p)) { - kill('SPLASH! Water is instant death.'); - } + if (isOverWater(p)) kill('SPLASH! Water is instant death.'); } else { p.onGround = false; } @@ -265,17 +309,6 @@ function updatePlayer(dt) { state.cameraX = Math.max(0, Math.min(worldWidth - canvas.width, p.x - 240)); } -function drawDeathText() { - if (!state.dead) return; - ctx.fillStyle = 'rgba(0,0,0,0.55)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#fff'; - ctx.font = 'bold 42px Arial'; - ctx.fillText('Game Over', 360, 220); - ctx.font = '24px Arial'; - ctx.fillText(state.deadReason, 280, 270); -} - function tick(ts) { const dt = Math.min((ts - state.lastTs) / 1000, 0.05); state.lastTs = ts; @@ -294,7 +327,6 @@ function tick(ts) { drawPterodactyls(); drawMeteors(); drawTRex(state.player); - drawDeathText(); scoreEl.textContent = `Score: ${state.score}`; updateHeartsUI();