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 startScreenEl = document.getElementById('startScreen'); const startFormEl = document.getElementById('startForm'); const passwordEl = document.getElementById('startPassword'); const accessModeEl = document.getElementById('accessMode'); const difficultyEl = document.getElementById('difficulty'); const startErrorEl = document.getElementById('startError'); const keys = new Set(); const gravity = 1900; const groundY = 420; const worldWidth = 13000; const DIFFICULTY = { easy: { moveSpeed: 0.88, hazardSpeed: 0.75, pteroSpawn: 3.0, meteorSpawn: 2.25, hitCooldown: 1.35, pteroUnlock: 140, meteorUnlock: 340 }, medium: { moveSpeed: 1.0, hazardSpeed: 1.0, pteroSpawn: 2.2, meteorSpawn: 1.5, hitCooldown: 1.0, pteroUnlock: 100, meteorUnlock: 250 }, hard: { moveSpeed: 1.18, hazardSpeed: 1.35, pteroSpawn: 1.45, meteorSpawn: 1.05, hitCooldown: 0.7, pteroUnlock: 70, meteorUnlock: 170 } }; const BIOMES = [ { name: 'Plains', start: 0, end: 2200, sky: '#8ddcff', ground: '#88d34f' }, { name: 'Desert', start: 2200, end: 5200, sky: '#f4c983', ground: '#d5ad63' }, { name: 'Jungle', start: 5200, end: 8000, sky: '#6ecb8c', ground: '#3d8b4a' }, { name: 'Snow', start: 8000, end: 10400, sky: '#d5e9ff', ground: '#edf5ff' }, { name: 'Lava', start: 10400, end: worldWidth, sky: '#48302b', ground: '#6b3b32' } ]; let audioCtx = null; let gameConfig = { accessMode: 'standard', difficulty: 'medium' }; 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 (_) {} } const baseWaterGaps = [ { x: 700, width: 130 }, { x: 1300, width: 150 }, { x: 2100, width: 170 }, { x: 3000, width: 140 }, { x: 3900, width: 200 } ]; const lavaPuddles = [ { x: 10600, width: 120 }, { x: 11180, width: 180 }, { x: 11850, width: 130 }, { x: 12380, width: 180 } ]; const cacti = [ { x: 2500, width: 28, height: 80 }, { x: 2960, width: 32, height: 92 }, { x: 3480, width: 24, height: 76 }, { x: 4020, width: 30, height: 96 }, { x: 4630, width: 32, height: 85 } ]; const monkeys = [ { x: 5600, y: groundY - 150, width: 58, height: 42, vx: -140, range: [5450, 6050] }, { x: 6500, y: groundY - 165, width: 62, height: 44, vx: 120, range: [6320, 7000] }, { x: 7450, y: groundY - 152, width: 58, height: 42, vx: -130, range: [7250, 7800] } ]; const sabertooths = [ { x: 5900, y: groundY - 42, width: 90, height: 42, vx: -160, range: [5600, 6800] }, { x: 7100, y: groundY - 42, width: 96, height: 42, vx: 150, range: [6800, 7900] } ]; const mammoths = [ { x: 8450, width: 160 }, { x: 9300, width: 170 }, { x: 9950, width: 165 } ]; function getBiomeAt(x) { return BIOMES.find((b) => x >= b.start && x < b.end) || BIOMES[BIOMES.length - 1]; } function getMaxHearts(accessMode) { if (accessMode === 'vip') return 10; return 5; } function getWaterGaps(difficulty) { if (difficulty !== 'easy') return baseWaterGaps; return baseWaterGaps.map((gap) => ({ ...gap, width: Math.round(gap.width * 0.74) })); } function getNextDifficulty(difficulty) { if (difficulty === 'easy') return 'medium'; if (difficulty === 'medium') return 'hard'; return null; } function createInitialState(config = gameConfig) { const maxHearts = getMaxHearts(config.accessMode); return { running: false, dead: false, deadReason: '', lastTs: performance.now(), score: 0, stepCarry: 0, cameraX: 0, hearts: maxHearts, maxHearts, hitCooldown: 0, floatingTexts: [], worldCleared: false, portal: { active: false, entered: false, // Keep portal close to the world edge so forward motion naturally carries // the player into it once progression is unlocked. x: worldWidth - 60, width: 52, height: 92, y: groundY - 92, pulse: 0 }, waterGaps: getWaterGaps(config.difficulty), 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 } ], monkeys: monkeys.map((m) => ({ ...m })), sabertooths: sabertooths.map((s) => ({ ...s })), mammoths: mammoths.map((m) => ({ ...m })), 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 * DIFFICULTY[config.difficulty].moveSpeed, jumpPower: 760, onGround: true, ducking: false } }; } let state = createInitialState(gameConfig); 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)); startFormEl.addEventListener('submit', (e) => { e.preventDefault(); const pass = passwordEl.value.trim(); if (pass !== '1027') { startErrorEl.textContent = 'Wrong password. Try 1027.'; return; } gameConfig = { accessMode: accessModeEl.value, difficulty: difficultyEl.value }; state = createInitialState(gameConfig); keys.clear(); scoreSavedThisRun = false; saveEligible = false; setSaveFormBusy(false); startScreenEl.classList.add('hidden'); deathScreenEl.classList.add('hidden'); state.running = true; startErrorEl.textContent = ''; }); function restartToStartScreen() { state = createInitialState(gameConfig); keys.clear(); deathScreenEl.classList.add('hidden'); saveScoreFormEl.classList.add('hidden'); saveEligible = false; scoreSavedThisRun = false; setSaveFormBusy(false); state.running = false; state.dead = false; accessModeEl.value = gameConfig.accessMode; difficultyEl.value = gameConfig.difficulty; passwordEl.value = ''; startErrorEl.textContent = ''; startScreenEl.classList.remove('hidden'); } restartBtnEl.addEventListener('click', restartToStartScreen); 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 drawBiomeParallax(biome) { const farOffset = -(state.cameraX * 0.15); const midOffset = -(state.cameraX * 0.35); if (biome.name === 'Plains') { ctx.fillStyle = 'rgba(255,255,255,0.7)'; for (let i = -1; i < 6; i++) ctx.fillRect(((i * 220 + farOffset) % 1300), 70 + (i % 2) * 18, 120, 22); ctx.fillStyle = 'rgba(72,140,72,0.55)'; for (let i = -1; i < 8; i++) ctx.fillRect(((i * 170 + midOffset) % 1500), groundY - 90, 90, 90); } else if (biome.name === 'Desert') { ctx.fillStyle = 'rgba(232,187,120,0.55)'; for (let i = -1; i < 7; i++) ctx.fillRect(((i * 190 + farOffset) % 1500), groundY - 120, 160, 120); ctx.fillStyle = 'rgba(180,135,70,0.6)'; for (let i = -1; i < 10; i++) ctx.fillRect(((i * 140 + midOffset) % 1600), groundY - 70, 26, 70); } else if (biome.name === 'Jungle') { ctx.fillStyle = 'rgba(40,106,50,0.45)'; for (let i = -1; i < 9; i++) ctx.fillRect(((i * 150 + farOffset) % 1600), groundY - 170, 80, 170); ctx.fillStyle = 'rgba(27,78,38,0.62)'; for (let i = -1; i < 12; i++) ctx.fillRect(((i * 118 + midOffset) % 1700), groundY - 115, 40, 115); } else if (biome.name === 'Snow') { ctx.fillStyle = 'rgba(255,255,255,0.75)'; for (let i = -1; i < 8; i++) ctx.fillRect(((i * 180 + farOffset) % 1600), groundY - 145, 140, 145); ctx.fillStyle = 'rgba(196,220,242,0.7)'; for (let i = -1; i < 10; i++) ctx.fillRect(((i * 140 + midOffset) % 1700), groundY - 92, 95, 92); } else if (biome.name === 'Lava') { ctx.fillStyle = 'rgba(98,58,54,0.7)'; for (let i = -1; i < 8; i++) ctx.fillRect(((i * 185 + farOffset) % 1600), groundY - 150, 150, 150); ctx.fillStyle = 'rgba(255,128,40,0.45)'; for (let i = -1; i < 14; i++) ctx.fillRect(((i * 112 + midOffset) % 1800), groundY - 85, 18, 85); } } function drawBackground() { const camCenter = state.cameraX + canvas.width / 2; const biome = getBiomeAt(camCenter); ctx.fillStyle = biome.sky; ctx.fillRect(0, 0, canvas.width, canvas.height); drawBiomeParallax(biome); ctx.fillStyle = biome.ground; ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY); // Water and lava pits for (const gap of state.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); } for (const lava of lavaPuddles) { const sx = lava.x - state.cameraX; if (sx + lava.width < 0 || sx > canvas.width) continue; ctx.fillStyle = '#ff5f1f'; ctx.fillRect(sx, groundY, lava.width, canvas.height - groundY); } // Cactus decoration + hazard body ctx.fillStyle = '#2a8f4a'; for (const c of cacti) { const sx = c.x - state.cameraX; if (sx + c.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, groundY - c.height, c.width, c.height); ctx.fillRect(sx - 8, groundY - c.height + 24, 8, 18); ctx.fillRect(sx + c.width, groundY - c.height + 26, 8, 18); } ctx.fillStyle = '#0f0f0f'; ctx.font = 'bold 20px Arial'; ctx.fillText(`Biome: ${biome.name}`, 18, 70); ctx.fillText(`Difficulty: ${gameConfig.difficulty.toUpperCase()}`, 18, 96); const modeLabel = gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD MODE'; ctx.fillText(modeLabel, 18, 122); } 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); } } function drawMonkeys() { ctx.fillStyle = '#6a3f1f'; for (const m of state.monkeys) { const sx = m.x - state.cameraX; if (sx + m.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, m.y, m.width, m.height); ctx.fillRect(sx + m.width / 2 - 3, m.y - 18, 6, 18); // vine } } function drawSabertooths() { ctx.fillStyle = '#c6884e'; for (const s of state.sabertooths) { const sx = s.x - state.cameraX; if (sx + s.width < 0 || sx > canvas.width) continue; ctx.fillRect(sx, s.y, s.width, s.height); ctx.fillStyle = '#fff'; ctx.fillRect(sx + s.width - 22, s.y + 26, 4, 12); ctx.fillRect(sx + s.width - 14, s.y + 26, 4, 12); ctx.fillStyle = '#c6884e'; } } function drawMammoths() { for (const m of state.mammoths) { const sx = m.x - state.cameraX; if (sx + m.width < 0 || sx > canvas.width) continue; // body ctx.fillStyle = '#7c8ea1'; ctx.fillRect(sx, groundY - 130, m.width, 70); // legs ctx.fillRect(sx + 14, groundY - 60, 24, 60); ctx.fillRect(sx + m.width - 38, groundY - 60, 24, 60); // tusk hint ctx.fillStyle = '#f3f3f3'; ctx.fillRect(sx + m.width - 6, groundY - 70, 8, 24); } } 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); } } 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 drawPortal() { if (!state.portal.active) return; const portal = state.portal; const sx = portal.x - state.cameraX; if (sx + portal.width < 0 || sx > canvas.width) return; const pulse = (Math.sin(portal.pulse * 6) + 1) / 2; const glowWidth = portal.width + 20; const glowHeight = portal.height + 16; ctx.save(); ctx.globalAlpha = 0.2 + pulse * 0.2; ctx.fillStyle = '#84c7ff'; ctx.fillRect(sx - 10, portal.y - 8, glowWidth, glowHeight); ctx.restore(); ctx.fillStyle = '#2a2a56'; ctx.fillRect(sx, portal.y, portal.width, portal.height); ctx.fillStyle = '#4eb4ff'; ctx.fillRect(sx + 4, portal.y + 4, portal.width - 8, portal.height - 8); for (let i = 0; i < 5; i++) { const stripeY = portal.y + 10 + i * 15; const wobble = Math.sin(portal.pulse * 7 + i * 0.9) * 4; ctx.fillStyle = i % 2 === 0 ? '#dbf4ff' : '#8bd9ff'; ctx.fillRect(sx + 10 + wobble, stripeY, portal.width - 20, 6); } ctx.fillStyle = '#88e1ff'; ctx.fillRect(sx - 4, portal.y + 2, 4, portal.height - 4); ctx.fillRect(sx + portal.width, portal.y + 2, 4, portal.height - 4); ctx.fillStyle = '#0f0f0f'; ctx.font = 'bold 14px Arial'; ctx.fillText('PORTAL', sx - 8, portal.y - 10); } function resetWorldForDifficulty(nextDifficulty) { const score = state.score; const hearts = state.hearts; const maxHearts = state.maxHearts; gameConfig.difficulty = nextDifficulty; const nextState = createInitialState(gameConfig); state = nextState; state.score = score; state.hearts = Math.min(hearts, maxHearts); state.maxHearts = maxHearts; state.running = true; state.dead = false; } function updatePortal(dt) { state.portal.pulse += dt; const nextDifficulty = getNextDifficulty(gameConfig.difficulty); if (!nextDifficulty) { state.portal.active = false; return; } const p = state.player; const worldEndX = worldWidth - p.width - 8; // Activate slightly before the hard clamp at world end so the player can // continue moving right and enter the portal without reversing direction. const portalActivationX = Math.max(0, state.portal.x - p.width - 24); const reachedProgressionPoint = p.x >= portalActivationX; if (reachedProgressionPoint) { state.portal.active = true; state.worldCleared = p.x >= worldEndX; } if (!state.portal.active || state.portal.entered) return; const portalHitbox = { x: state.portal.x, y: state.portal.y, width: state.portal.width, height: state.portal.height }; if (intersects(p, portalHitbox)) { state.portal.entered = true; addFloatingText(state.portal.x + state.portal.width / 2, state.portal.y - 14, `${nextDifficulty.toUpperCase()}!`, '#9fe6ff'); resetWorldForDifficulty(nextDifficulty); } } 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 = ''.repeat(state.hearts); const lost = ''.repeat(Math.max(0, state.maxHearts - 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} • ${reason}`; await populateLeaderboard(); deathScreenEl.classList.remove('hidden'); } function applyHit(reason) { if (state.hitCooldown > 0 || state.dead) return; state.hearts -= 1; state.hitCooldown = DIFFICULTY[gameConfig.difficulty].hitCooldown; playSfx('hit'); if (state.hearts <= 0) kill(reason); } function isOverWater(player) { const centerX = player.x + player.width / 2; return state.waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width); } function isOverLava(player) { const centerX = player.x + player.width / 2; return lavaPuddles.some((l) => centerX >= l.x && centerX <= l.x + l.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 > 5100) 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.'); } } } } function updateCactus() { const p = state.player; for (const c of cacti) { const hitbox = { x: c.x, y: groundY - c.height, width: c.width, height: c.height }; if (intersects(p, hitbox)) applyHit('Cactus spike!'); } } function updateMonkeys(dt) { const p = state.player; for (const m of state.monkeys) { m.x += m.vx * dt; if (m.x < m.range[0]) m.vx = Math.abs(m.vx); if (m.x > m.range[1]) m.vx = -Math.abs(m.vx); if (intersects(p, m) && !p.ducking) { applyHit('Monkey swing! Duck under them.'); } } } function updateSabertooths(dt) { const p = state.player; for (const s of state.sabertooths) { s.x += s.vx * dt; if (s.x < s.range[0]) s.vx = Math.abs(s.vx); if (s.x > s.range[1]) s.vx = -Math.abs(s.vx); if (intersects(p, s)) applyHit('Sabertooth tiger attack!'); } } function updateMammoths() { const p = state.player; for (const m of state.mammoths) { const body = { x: m.x, y: groundY - 130, width: m.width, height: 70 }; const leftLeg = { x: m.x + 14, y: groundY - 60, width: 24, height: 60 }; const rightLeg = { x: m.x + m.width - 38, y: groundY - 60, width: 24, height: 60 }; if (intersects(p, body) || intersects(p, leftLeg) || intersects(p, rightLeg)) { if (!p.ducking) { applyHit('Mammoth stomp! Duck through the legs.'); } } } } const PTERODACTYL_LANES = [{ 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; return { x: player.x + widthInset, y: player.y + topInset, width: player.width - widthInset * 2, height: player.height - topInset - 2 }; } function updatePterodactyls(dt) { if (state.score <= DIFFICULTY[gameConfig.difficulty].pteroUnlock) return; state.pteroSpawnTimer -= dt; if (state.pteroSpawnTimer <= 0) { const lane = PTERODACTYL_LANES[0]; const speedMul = DIFFICULTY[gameConfig.difficulty].hazardSpeed; state.pterodactyls.push({ x: state.cameraX + canvas.width + 80, y: lane.y, width: 54, height: lane.height, vx: -280 * speedMul }); playSfx('ptero_spawn'); state.pteroSpawnTimer = DIFFICULTY[gameConfig.difficulty].pteroSpawn; } 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 <= DIFFICULTY[gameConfig.difficulty].meteorUnlock) 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 * DIFFICULTY[gameConfig.difficulty].hazardSpeed, landed: false }); playSfx('meteor_spawn'); state.meteorSpawnTimer = DIFFICULTY[gameConfig.difficulty].meteorSpawn; } 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.'); if (isOverLava(p)) kill('LAVA! 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); updatePortal(dt); updateAnts(dt); updateCactus(); updateMonkeys(dt); updateSabertooths(dt); updateMammoths(); updatePterodactyls(dt); updateMeteors(dt); updateFloatingTexts(dt); } ctx.clearRect(0, 0, canvas.width, canvas.height); drawBackground(); drawAnts(); drawMonkeys(); drawSabertooths(); drawMammoths(); drawPterodactyls(); drawMeteors(); drawPortal(); drawFloatingTexts(); drawTRex(state.player); scoreEl.textContent = `Score: ${state.score}`; updateHeartsUI(); requestAnimationFrame(tick); } requestAnimationFrame((ts) => { state.lastTs = ts; updateHeartsUI(); requestAnimationFrame(tick); });