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 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();