478 lines
13 KiB
JavaScript
478 lines
13 KiB
JavaScript
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;
|
|
const groundY = 420;
|
|
const worldWidth = 6000;
|
|
|
|
let audioCtx = null;
|
|
|
|
function ensureAudioCtx() {
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
}
|
|
|
|
function playSfx(type) {
|
|
try {
|
|
ensureAudioCtx();
|
|
const now = audioCtx.currentTime;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
|
|
const presets = {
|
|
jump: { f0: 420, f1: 680, dur: 0.1, wave: 'triangle', vol: 0.06 },
|
|
squish: { f0: 180, f1: 120, dur: 0.12, wave: 'square', vol: 0.07 },
|
|
duck: { f0: 240, f1: 180, dur: 0.06, wave: 'sine', vol: 0.05 },
|
|
ptero_spawn: { f0: 520, f1: 460, dur: 0.1, wave: 'sawtooth', vol: 0.05 },
|
|
meteor_spawn: { f0: 300, f1: 200, dur: 0.14, wave: 'sawtooth', vol: 0.06 },
|
|
meteor_land: { f0: 140, f1: 70, dur: 0.18, wave: 'square', vol: 0.08 },
|
|
hit: { f0: 260, f1: 140, dur: 0.1, wave: 'square', vol: 0.06 }
|
|
};
|
|
|
|
const p = presets[type];
|
|
if (!p) return;
|
|
osc.type = p.wave;
|
|
osc.frequency.setValueAtTime(p.f0, now);
|
|
osc.frequency.exponentialRampToValueAtTime(Math.max(40, p.f1), now + p.dur);
|
|
gain.gain.setValueAtTime(p.vol, now);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + p.dur);
|
|
osc.start(now);
|
|
osc.stop(now + p.dur);
|
|
} catch (_) {
|
|
// ignore audio errors in unsupported environments
|
|
}
|
|
}
|
|
|
|
const waterGaps = [
|
|
{ x: 700, width: 130 },
|
|
{ x: 1300, width: 150 },
|
|
{ x: 2100, width: 170 },
|
|
{ x: 3000, width: 140 },
|
|
{ x: 3900, width: 200 }
|
|
];
|
|
|
|
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,
|
|
floatingTexts: [],
|
|
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;
|
|
let scoreSavedThisRun = false;
|
|
let saveInFlight = false;
|
|
|
|
const saveScoreBtnEl = saveScoreFormEl.querySelector('button[type="submit"]');
|
|
|
|
function setSaveFormBusy(isBusy) {
|
|
saveInFlight = isBusy;
|
|
playerNameEl.disabled = isBusy;
|
|
if (saveScoreBtnEl) saveScoreBtnEl.disabled = isBusy;
|
|
}
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) e.preventDefault();
|
|
keys.add(e.key);
|
|
if (e.key === 'ArrowUp' && state.player.onGround && state.running) {
|
|
state.player.vy = -state.player.jumpPower;
|
|
state.player.onGround = false;
|
|
playSfx('jump');
|
|
}
|
|
});
|
|
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;
|
|
scoreSavedThisRun = false;
|
|
setSaveFormBusy(false);
|
|
});
|
|
|
|
saveScoreFormEl.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!saveEligible || saveInFlight || scoreSavedThisRun) return;
|
|
const name = playerNameEl.value.trim() || 'Anonymous';
|
|
|
|
setSaveFormBusy(true);
|
|
try {
|
|
const res = await fetch('api/scores.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, score: state.score })
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Score save failed');
|
|
|
|
saveEligible = false;
|
|
scoreSavedThisRun = true;
|
|
saveScoreFormEl.classList.add('hidden');
|
|
await populateLeaderboard();
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setSaveFormBusy(false);
|
|
}
|
|
});
|
|
|
|
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);
|
|
|
|
for (const gap of waterGaps) {
|
|
const sx = gap.x - state.cameraX;
|
|
if (sx + gap.width < 0 || sx > canvas.width) continue;
|
|
ctx.fillStyle = '#2e8bcf';
|
|
ctx.fillRect(sx, groundY, gap.width, canvas.height - groundY);
|
|
}
|
|
}
|
|
|
|
function drawTRex(p) {
|
|
const sx = p.x - state.cameraX;
|
|
ctx.fillStyle = '#35535f';
|
|
ctx.fillRect(sx, p.y, p.width, p.height);
|
|
ctx.fillStyle = '#27404a';
|
|
ctx.fillRect(sx + p.width - 12, p.y + 14, 7, 7);
|
|
ctx.fillRect(sx + 10, p.y + p.height - 8, 14, 8);
|
|
ctx.fillRect(sx + p.width - 24, p.y + p.height - 8, 14, 8);
|
|
}
|
|
|
|
function drawAnts() {
|
|
ctx.fillStyle = '#5a1e0d';
|
|
for (const ant of state.ants) {
|
|
if (!ant.alive) continue;
|
|
const sx = ant.x - state.cameraX;
|
|
if (sx + ant.width < 0 || sx > canvas.width) continue;
|
|
ctx.fillRect(sx, ant.y, ant.width, ant.height);
|
|
ctx.fillRect(sx - 4, ant.y + 6, 4, 4);
|
|
ctx.fillRect(sx + ant.width, ant.y + 6, 4, 4);
|
|
}
|
|
}
|
|
|
|
function drawPterodactyls() {
|
|
ctx.fillStyle = '#6e5a8a';
|
|
for (const p of state.pterodactyls) {
|
|
const sx = p.x - state.cameraX;
|
|
if (sx + p.width < 0 || sx > canvas.width) continue;
|
|
ctx.fillRect(sx, p.y, p.width, p.height);
|
|
ctx.fillRect(sx - 8, p.y + 6, 8, 4);
|
|
ctx.fillRect(sx + p.width, p.y + 6, 8, 4);
|
|
}
|
|
}
|
|
|
|
function drawMeteors() {
|
|
ctx.fillStyle = '#6b4d2e';
|
|
for (const m of state.meteors) {
|
|
const sx = m.x - state.cameraX;
|
|
if (sx + m.width < 0 || sx > canvas.width) continue;
|
|
ctx.fillRect(sx, m.y, m.width, m.height);
|
|
}
|
|
}
|
|
|
|
function addFloatingText(x, y, text, color = '#ffe066') {
|
|
state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 });
|
|
}
|
|
|
|
function updateFloatingTexts(dt) {
|
|
for (const t of state.floatingTexts) {
|
|
t.ttl -= dt;
|
|
t.y += t.vy * dt;
|
|
}
|
|
state.floatingTexts = state.floatingTexts.filter((t) => t.ttl > 0);
|
|
}
|
|
|
|
function drawFloatingTexts() {
|
|
ctx.save();
|
|
ctx.font = 'bold 22px Arial';
|
|
ctx.textAlign = 'center';
|
|
for (const t of state.floatingTexts) {
|
|
const alpha = Math.max(0, Math.min(1, t.ttl / 0.8));
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = t.color;
|
|
ctx.fillText(t.text, t.x - state.cameraX, t.y);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
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() {
|
|
const active = '<span class="heart heart-active">❤</span>'.repeat(state.hearts);
|
|
const lost = '<span class="heart heart-lost">❤</span>'.repeat(Math.max(0, 3 - state.hearts));
|
|
heartsEl.innerHTML = active + lost;
|
|
}
|
|
|
|
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 && !scoreSavedThisRun && !saveInFlight;
|
|
saveScoreFormEl.classList.toggle('hidden', !saveEligible);
|
|
}
|
|
|
|
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;
|
|
playSfx('hit');
|
|
if (state.hearts <= 0) kill(reason);
|
|
}
|
|
|
|
function isOverWater(player) {
|
|
const centerX = player.x + player.width / 2;
|
|
return waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width);
|
|
}
|
|
|
|
function updateAnts(dt) {
|
|
const p = state.player;
|
|
const prevBottom = p.y + p.height - p.vy * 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);
|
|
|
|
if (intersects(p, ant)) {
|
|
const playerBottom = p.y + p.height;
|
|
const stomped = p.vy > 0 && prevBottom <= ant.y + 4 && playerBottom >= ant.y;
|
|
if (stomped) {
|
|
ant.alive = false;
|
|
p.vy = -380;
|
|
state.score += 50;
|
|
addFloatingText(ant.x + ant.width / 2, ant.y - 10, '+50', '#ffe066');
|
|
playSfx('squish');
|
|
} else {
|
|
applyHit('A swarm of ants got you.');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const PTERODACTYL_LANES = [
|
|
// low lane: standing dino collides, ducking dino should pass underneath
|
|
{ y: groundY - 80, height: 28 }
|
|
];
|
|
|
|
function getPterodactylHitbox(flyer) {
|
|
return {
|
|
x: flyer.x + 8,
|
|
y: flyer.y + 6,
|
|
width: flyer.width - 16,
|
|
height: flyer.height - 12
|
|
};
|
|
}
|
|
|
|
function getPlayerHitboxForAirHazards(player) {
|
|
const widthInset = player.ducking ? 8 : 6;
|
|
const topInset = player.ducking ? 8 : 4;
|
|
const bottomInset = 2;
|
|
|
|
return {
|
|
x: player.x + widthInset,
|
|
y: player.y + topInset,
|
|
width: player.width - widthInset * 2,
|
|
height: player.height - topInset - bottomInset
|
|
};
|
|
}
|
|
|
|
function updatePterodactyls(dt) {
|
|
if (state.score <= 100) return;
|
|
state.pteroSpawnTimer -= dt;
|
|
|
|
if (state.pteroSpawnTimer <= 0) {
|
|
const lane = PTERODACTYL_LANES[Math.floor(Math.random() * PTERODACTYL_LANES.length)];
|
|
state.pterodactyls.push({
|
|
x: state.cameraX + canvas.width + 80,
|
|
y: lane.y,
|
|
width: 54,
|
|
height: lane.height,
|
|
vx: -280
|
|
});
|
|
playSfx('ptero_spawn');
|
|
state.pteroSpawnTimer = 2.2;
|
|
}
|
|
|
|
const p = state.player;
|
|
const playerHitbox = getPlayerHitboxForAirHazards(p);
|
|
for (const flyer of state.pterodactyls) {
|
|
flyer.x += flyer.vx * dt;
|
|
if (intersects(playerHitbox, getPterodactylHitbox(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, landed: false });
|
|
playSfx('meteor_spawn');
|
|
state.meteorSpawnTimer = 1.5;
|
|
}
|
|
|
|
const p = state.player;
|
|
for (const m of state.meteors) {
|
|
m.y += m.vy * dt;
|
|
|
|
if (!m.landed && m.y + m.height >= groundY) {
|
|
m.landed = true;
|
|
playSfx('meteor_land');
|
|
}
|
|
|
|
if (intersects(p, m)) {
|
|
applyHit('Meteor strike!');
|
|
m.y = canvas.height + 100;
|
|
}
|
|
}
|
|
state.meteors = state.meteors.filter((m) => m.y < canvas.height + 80);
|
|
}
|
|
|
|
function updatePlayer(dt) {
|
|
const p = state.player;
|
|
let move = 0;
|
|
if (keys.has('ArrowLeft')) move -= 1;
|
|
if (keys.has('ArrowRight')) move += 1;
|
|
|
|
const wasDucking = p.ducking;
|
|
p.ducking = keys.has('ArrowDown') && p.onGround;
|
|
if (!wasDucking && p.ducking) playSfx('duck');
|
|
const targetHeight = p.ducking ? p.duckHeight : p.standingHeight;
|
|
if (targetHeight !== p.height) {
|
|
p.y += p.height - targetHeight;
|
|
p.height = targetHeight;
|
|
}
|
|
|
|
p.vx = move * p.speed;
|
|
const oldX = p.x;
|
|
p.x += p.vx * dt;
|
|
p.x = Math.max(0, Math.min(worldWidth - p.width, p.x));
|
|
|
|
if (p.x > oldX) {
|
|
state.stepCarry += p.x - oldX;
|
|
while (state.stepCarry >= 8) {
|
|
state.score += 1;
|
|
state.stepCarry -= 8;
|
|
}
|
|
}
|
|
|
|
p.vy += gravity * dt;
|
|
p.y += p.vy * dt;
|
|
|
|
if (p.y + p.height >= groundY) {
|
|
p.y = groundY - p.height;
|
|
p.vy = 0;
|
|
p.onGround = true;
|
|
if (isOverWater(p)) kill('SPLASH! Water is instant death.');
|
|
} else {
|
|
p.onGround = false;
|
|
}
|
|
|
|
state.cameraX = Math.max(0, Math.min(worldWidth - canvas.width, p.x - 240));
|
|
}
|
|
|
|
function tick(ts) {
|
|
const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
|
|
state.lastTs = ts;
|
|
|
|
if (state.running) {
|
|
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
|
|
updatePlayer(dt);
|
|
updateAnts(dt);
|
|
updatePterodactyls(dt);
|
|
updateMeteors(dt);
|
|
updateFloatingTexts(dt);
|
|
}
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
drawBackground();
|
|
drawAnts();
|
|
drawPterodactyls();
|
|
drawMeteors();
|
|
drawFloatingTexts();
|
|
drawTRex(state.player);
|
|
|
|
scoreEl.textContent = `Score: ${state.score}`;
|
|
updateHeartsUI();
|
|
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
requestAnimationFrame((ts) => {
|
|
state.lastTs = ts;
|
|
updateHeartsUI();
|
|
requestAnimationFrame(tick);
|
|
});
|