DinoLand/js/game.js

1117 lines
33 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 musicToggleEl = document.getElementById('musicToggle');
const MUSIC_STORAGE_KEY = 'dinoLand.musicEnabled';
function resolveInitialMusicEnabled() {
try {
const raw = window.localStorage.getItem(MUSIC_STORAGE_KEY);
if (raw === null) return false;
return raw !== '0' && raw !== 'false';
} catch (_) {
return false;
}
}
function persistMusicEnabled(enabled) {
try {
window.localStorage.setItem(MUSIC_STORAGE_KEY, enabled ? '1' : '0');
} catch (_) {
// no-op in restricted contexts
}
}
let musicEnabled = resolveInitialMusicEnabled();
function refreshMusicToggleUi() {
if (!musicToggleEl) return;
musicToggleEl.textContent = musicEnabled ? '🎵 Music: On' : '🔇 Music: Off';
musicToggleEl.setAttribute('aria-pressed', musicEnabled ? 'true' : 'false');
musicToggleEl.setAttribute('title', musicEnabled ? 'Disable music' : 'Enable music (first click also unlocks audio)');
}
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' };
const musicState = {
bus: null,
schedulerId: null,
nextNoteTime: 0,
step: 0,
started: false,
mode: 'title',
profile: 'title'
};
const MUSIC_STEP_SECONDS = 0.24;
const MUSIC_SCHEDULE_AHEAD_SECONDS = 0.6;
const MUSIC_LOOKAHEAD_MS = 80;
const MUSIC_ON_GAIN = 0.06;
const MUSIC_PROFILES = {
title: {
sequence: [
[196, 246.94, 293.66],
[174.61, 220, 261.63],
[164.81, 207.65, 246.94],
[174.61, 220, 261.63]
]
},
gameplay_plains: {
sequence: [
[220, 277.18, 329.63],
[246.94, 311.13, 369.99],
[196, 246.94, 293.66],
[174.61, 220, 261.63]
]
},
gameplay_desert: {
sequence: [
[164.81, 196, 246.94],
[174.61, 220, 261.63],
[196, 233.08, 293.66],
[174.61, 220, 261.63]
]
},
gameplay_jungle: {
sequence: [
[246.94, 311.13, 369.99],
[261.63, 329.63, 392.0],
[220, 277.18, 329.63],
[233.08, 293.66, 349.23]
]
},
gameplay_snow: {
sequence: [
[196, 246.94, 293.66],
[220, 277.18, 329.63],
[174.61, 220, 261.63],
[196, 246.94, 293.66]
]
},
gameplay_volcano: {
sequence: [
[138.59, 174.61, 207.65],
[146.83, 185, 220],
[130.81, 164.81, 196],
[123.47, 155.56, 185]
]
},
death: {
sequence: [
[130.81, 155.56, 185],
[123.47, 146.83, 174.61],
[110, 138.59, 164.81],
[98, 123.47, 146.83]
]
}
};
function ensureAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function ensureMusicBus() {
const ctx = ensureAudioCtx();
if (musicState.bus) return musicState.bus;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.connect(ctx.destination);
musicState.bus = gain;
return gain;
}
function scheduleMusicNote(frequency, time, duration = MUSIC_STEP_SECONDS * 0.95) {
if (!musicState.bus) return;
const ctx = ensureAudioCtx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(frequency, time);
gain.gain.setValueAtTime(0.0001, time);
gain.gain.exponentialRampToValueAtTime(0.16, time + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, time + duration);
osc.connect(gain);
gain.connect(musicState.bus);
osc.start(time);
osc.stop(time + duration + 0.03);
}
function resolveMusicProfile() {
return MUSIC_PROFILES[musicState.profile] || MUSIC_PROFILES.title;
}
function normalizeBiomeMusicKey(name) {
const raw = String(name || '').toLowerCase();
if (raw === 'lava') return 'volcano';
if (raw === 'volcano') return 'volcano';
if (raw === 'plains' || raw === 'desert' || raw === 'jungle' || raw === 'snow') return raw;
return 'plains';
}
function setMusicMode(mode, profile) {
if (!profile || !MUSIC_PROFILES[profile]) return;
const profileChanged = musicState.profile !== profile;
musicState.mode = mode;
musicState.profile = profile;
if (profileChanged) {
musicState.step = 0;
}
}
function updateMusicForGameState(force = false) {
let nextMode = 'title';
let nextProfile = 'title';
if (state.dead) {
nextMode = 'death';
nextProfile = 'death';
} else if (state.running) {
nextMode = 'gameplay';
const focusX = state.player.x + state.player.width * 0.5;
const biome = getBiomeAt(focusX);
const biomeKey = normalizeBiomeMusicKey(biome.name);
nextProfile = `gameplay_${biomeKey}`;
}
if (force || musicState.mode !== nextMode || musicState.profile !== nextProfile) {
setMusicMode(nextMode, nextProfile);
}
}
function runMusicScheduler() {
if (!musicState.started || !audioCtx) return;
const ctx = audioCtx;
while (musicState.nextNoteTime < ctx.currentTime + MUSIC_SCHEDULE_AHEAD_SECONDS) {
const profile = resolveMusicProfile();
const chord = profile.sequence[musicState.step % profile.sequence.length];
const root = chord[0];
const third = chord[1];
const fifth = chord[2];
const bass = root / 2;
scheduleMusicNote(bass, musicState.nextNoteTime, MUSIC_STEP_SECONDS * 1.9);
scheduleMusicNote(root, musicState.nextNoteTime, MUSIC_STEP_SECONDS * 0.95);
scheduleMusicNote(third, musicState.nextNoteTime + MUSIC_STEP_SECONDS * 0.5, MUSIC_STEP_SECONDS * 0.9);
scheduleMusicNote(fifth, musicState.nextNoteTime + MUSIC_STEP_SECONDS, MUSIC_STEP_SECONDS * 0.9);
musicState.nextNoteTime += MUSIC_STEP_SECONDS * 2;
musicState.step += 1;
}
}
function startMusicIfNeeded() {
const ctx = ensureAudioCtx();
ensureMusicBus();
if (musicState.started) return;
musicState.started = true;
musicState.nextNoteTime = ctx.currentTime + 0.05;
musicState.step = 0;
updateMusicForGameState(true);
runMusicScheduler();
musicState.schedulerId = window.setInterval(runMusicScheduler, MUSIC_LOOKAHEAD_MS);
}
function setMusicEnabledAudio(enabled) {
const ctx = ensureAudioCtx();
const bus = ensureMusicBus();
if (enabled) startMusicIfNeeded();
const now = ctx.currentTime;
bus.gain.cancelScheduledValues(now);
bus.gain.setValueAtTime(Math.max(0.0001, bus.gain.value), now);
bus.gain.exponentialRampToValueAtTime(enabled ? MUSIC_ON_GAIN : 0.0001, now + 0.35);
}
async function resumeAudioContext() {
const ctx = ensureAudioCtx();
if (ctx.state !== 'suspended') return true;
try {
await ctx.resume();
return true;
} catch (_) {
return false;
}
}
function playSfx(type) {
if (!musicEnabled) return;
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.5) }));
}
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));
if (musicToggleEl) {
refreshMusicToggleUi();
musicToggleEl.addEventListener('click', async (ev) => {
ev.preventDefault();
ev.stopPropagation();
const nextEnabled = !musicEnabled;
if (nextEnabled) {
await resumeAudioContext();
}
musicEnabled = nextEnabled;
persistMusicEnabled(musicEnabled);
refreshMusicToggleUi();
updateMusicForGameState(true);
setMusicEnabledAudio(musicEnabled);
});
}
startFormEl.addEventListener('submit', (e) => {
e.preventDefault();
const pass = passwordEl.value.trim();
if (pass !== '1027') {
startErrorEl.textContent = 'Wrong password. Ask Tanner.';
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 = '';
updateMusicForGameState(true);
if (musicEnabled) {
resumeAudioContext().then((resumed) => {
if (resumed) setMusicEnabledAudio(true);
});
}
});
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');
updateMusicForGameState(true);
}
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 = '<span class="heart heart-active">❤</span>'.repeat(state.hearts);
const lost = '<span class="heart heart-lost">❤</span>'.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;
updateMusicForGameState(true);
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;
updateMusicForGameState();
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();
updateMusicForGameState(true);
requestAnimationFrame(tick);
});