feat: add death screen with top-10 leaderboard and PHP score persistence
This commit is contained in:
parent
a3e49dffb9
commit
38ec8bb1ef
63
api/scores.php
Normal file
63
api/scores.php
Normal 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
1
data/scores.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
||||
178
js/game.js
178
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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user