feat: add death screen with top-10 leaderboard and PHP score persistence

This commit is contained in:
OpenClaw Engineer
2026-03-03 20:42:59 -06:00
parent a3e49dffb9
commit 38ec8bb1ef
3 changed files with 169 additions and 73 deletions
+105 -73
View File
@@ -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();