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

63
api/scores.php Normal file
View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json');
$storageDir = __DIR__ . '/../data';
$storageFile = $storageDir . '/scores.json';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0777, true);
}
if (!file_exists($storageFile)) {
file_put_contents($storageFile, json_encode([], JSON_PRETTY_PRINT));
}
function loadScores(string $file): array {
$raw = file_get_contents($file);
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function saveScores(string $file, array $scores): void {
usort($scores, fn($a, $b) => ($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']);

1
data/scores.json Normal file
View File

@ -0,0 +1 @@
[]

View File

@ -2,6 +2,12 @@ const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score'); const scoreEl = document.getElementById('score');
const heartsEl = document.getElementById('hearts'); 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 keys = new Set();
const gravity = 1900; const gravity = 1900;
@ -16,62 +22,87 @@ const waterGaps = [
{ x: 3900, width: 200 } { x: 3900, width: 200 }
]; ];
const state = { function createInitialState() {
running: true, return {
dead: false, running: true,
deadReason: '', dead: false,
lastTs: performance.now(), deadReason: '',
score: 0, lastTs: performance.now(),
stepCarry: 0, score: 0,
cameraX: 0, stepCarry: 0,
hearts: 3, cameraX: 0,
hitCooldown: 0, hearts: 3,
ants: [ hitCooldown: 0,
{ x: 1000, y: groundY - 20, width: 22, height: 20, vx: -65, alive: true }, ants: [
{ x: 1750, y: groundY - 20, width: 22, height: 20, vx: -70, alive: true }, { x: 1000, y: groundY - 20, width: 22, height: 20, vx: -65, alive: true },
{ x: 2600, y: groundY - 20, width: 22, height: 20, vx: -80, alive: true }, { x: 1750, y: groundY - 20, width: 22, height: 20, vx: -70, alive: true },
{ x: 3400, y: groundY - 20, width: 22, height: 20, vx: -75, 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, pterodactyls: [],
meteors: [], pteroSpawnTimer: 0,
meteorSpawnTimer: 0, meteors: [],
player: { meteorSpawnTimer: 0,
x: 160, player: {
y: 0, x: 160,
width: 64, y: groundY - 76,
height: 76, width: 64,
standingHeight: 76, height: 76,
duckHeight: 48, standingHeight: 76,
vx: 0, duckHeight: 48,
vy: 0, vx: 0,
speed: 300, vy: 0,
jumpPower: 760, speed: 300,
onGround: true, jumpPower: 760,
ducking: false onGround: true,
} ducking: false
}; }
state.player.y = groundY - state.player.height; };
}
let state = createInitialState();
let saveEligible = false;
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) e.preventDefault();
e.preventDefault();
}
keys.add(e.key); 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.vy = -state.player.jumpPower;
state.player.onGround = false; state.player.onGround = false;
} }
}); });
window.addEventListener('keyup', (e) => keys.delete(e.key)); 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() { function drawBackground() {
ctx.fillStyle = '#8ddcff'; ctx.fillStyle = '#8ddcff';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#88d34f'; ctx.fillStyle = '#88d34f';
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY); ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
// Water gaps
for (const gap of waterGaps) { for (const gap of waterGaps) {
const sx = gap.x - state.cameraX; const sx = gap.x - state.cameraX;
if (sx + gap.width < 0 || sx > canvas.width) continue; if (sx + gap.width < 0 || sx > canvas.width) continue;
@ -122,27 +153,47 @@ function drawMeteors() {
} }
} }
function intersects(a, b) { 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;
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;
}
function updateHeartsUI() { function updateHeartsUI() {
heartsEl.textContent = '❤'.repeat(state.hearts) + '🖤'.repeat(Math.max(0, 3 - state.hearts)); 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.running = false;
state.dead = true; state.dead = true;
state.deadReason = reason; state.deadReason = reason;
finalScoreEl.textContent = `Final Score: ${state.score}`;
await populateLeaderboard();
deathScreenEl.classList.remove('hidden');
} }
function applyHit(reason) { function applyHit(reason) {
if (state.hitCooldown > 0 || state.dead) return; if (state.hitCooldown > 0 || state.dead) return;
state.hearts -= 1; state.hearts -= 1;
state.hitCooldown = 1.0; state.hitCooldown = 1;
if (state.hearts <= 0) { if (state.hearts <= 0) kill(reason);
kill(reason);
}
} }
function isOverWater(player) { function isOverWater(player) {
@ -156,7 +207,6 @@ function updateAnts(dt) {
for (const ant of state.ants) { for (const ant of state.ants) {
if (!ant.alive) continue; if (!ant.alive) continue;
ant.x += ant.vx * dt; ant.x += ant.vx * dt;
if (ant.x < 200) ant.vx = Math.abs(ant.vx); if (ant.x < 200) ant.vx = Math.abs(ant.vx);
if (ant.x > worldWidth - 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) { function updatePterodactyls(dt) {
if (state.score <= 100) return; if (state.score <= 100) return;
state.pteroSpawnTimer -= dt; state.pteroSpawnTimer -= dt;
if (state.pteroSpawnTimer <= 0) { if (state.pteroSpawnTimer <= 0) {
const altitude = groundY - (state.player.standingHeight - 24);
state.pterodactyls.push({ state.pterodactyls.push({
x: state.cameraX + canvas.width + 80, x: state.cameraX + canvas.width + 80,
y: altitude, y: groundY - (state.player.standingHeight - 24),
width: 54, width: 54,
height: 26, height: 26,
vx: -280 vx: -280
@ -193,17 +242,15 @@ function updatePterodactyls(dt) {
const p = state.player; const p = state.player;
for (const flyer of state.pterodactyls) { for (const flyer of state.pterodactyls) {
flyer.x += flyer.vx * dt; flyer.x += flyer.vx * dt;
if (intersects(p, flyer)) { if (intersects(p, flyer)) applyHit('Pterodactyl collision. Duck next time!');
applyHit('Pterodactyl collision. Duck next time!');
}
} }
state.pterodactyls = state.pterodactyls.filter((f) => f.x + f.width > state.cameraX - 150); state.pterodactyls = state.pterodactyls.filter((f) => f.x + f.width > state.cameraX - 150);
} }
function updateMeteors(dt) { function updateMeteors(dt) {
if (state.score <= 250) return; if (state.score <= 250) return;
state.meteorSpawnTimer -= dt; state.meteorSpawnTimer -= dt;
if (state.meteorSpawnTimer <= 0) { if (state.meteorSpawnTimer <= 0) {
const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160); const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160);
state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420 }); 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.y = groundY - p.height;
p.vy = 0; p.vy = 0;
p.onGround = true; p.onGround = true;
if (isOverWater(p)) kill('SPLASH! Water is instant death.');
if (isOverWater(p)) {
kill('SPLASH! Water is instant death.');
}
} else { } else {
p.onGround = false; p.onGround = false;
} }
@ -265,17 +309,6 @@ function updatePlayer(dt) {
state.cameraX = Math.max(0, Math.min(worldWidth - canvas.width, p.x - 240)); 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) { function tick(ts) {
const dt = Math.min((ts - state.lastTs) / 1000, 0.05); const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
state.lastTs = ts; state.lastTs = ts;
@ -294,7 +327,6 @@ function tick(ts) {
drawPterodactyls(); drawPterodactyls();
drawMeteors(); drawMeteors();
drawTRex(state.player); drawTRex(state.player);
drawDeathText();
scoreEl.textContent = `Score: ${state.score}`; scoreEl.textContent = `Score: ${state.score}`;
updateHeartsUI(); updateHeartsUI();