diff --git a/index.php b/index.php
index 77624c6..c00767a 100644
--- a/index.php
+++ b/index.php
@@ -14,6 +14,30 @@
+
TRex Down!
diff --git a/js/game.js b/js/game.js
index c6a5221..a9102d6 100644
--- a/js/game.js
+++ b/js/game.js
@@ -9,12 +9,34 @@ 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 vipModeEl = document.getElementById('vipMode');
+const difficultyEl = document.getElementById('difficulty');
+const startErrorEl = document.getElementById('startError');
+
const keys = new Set();
const gravity = 1900;
const groundY = 420;
-const worldWidth = 6000;
+const worldWidth = 13000;
+
+const DIFFICULTY = {
+ easy: { moveSpeed: 0.9, hazardSpeed: 0.85, pteroSpawn: 2.6, meteorSpawn: 1.9 },
+ medium: { moveSpeed: 1.0, hazardSpeed: 1.0, pteroSpawn: 2.2, meteorSpawn: 1.5 },
+ hard: { moveSpeed: 1.15, hazardSpeed: 1.25, pteroSpawn: 1.7, meteorSpawn: 1.2 }
+};
+
+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 = { vipMode: false, difficulty: 'medium' };
function ensureAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
@@ -49,9 +71,7 @@ function playSfx(type) {
gain.gain.exponentialRampToValueAtTime(0.0001, now + p.dur);
osc.start(now);
osc.stop(now + p.dur);
- } catch (_) {
- // ignore audio errors in unsupported environments
- }
+ } catch (_) {}
}
const waterGaps = [
@@ -62,28 +82,69 @@ const waterGaps = [
{ x: 3900, width: 200 }
];
-function createInitialState() {
+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 createInitialState(config = gameConfig) {
+ const maxHearts = config.vipMode ? 6 : 3;
return {
- running: true,
+ running: false,
dead: false,
deadReason: '',
lastTs: performance.now(),
score: 0,
stepCarry: 0,
cameraX: 0,
- hearts: 3,
+ hearts: maxHearts,
+ maxHearts,
hitCooldown: 0,
+ floatingTexts: [],
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,
- floatingTexts: [],
player: {
x: 160,
y: groundY - 76,
@@ -93,7 +154,7 @@ function createInitialState() {
duckHeight: 48,
vx: 0,
vy: 0,
- speed: 300,
+ speed: 300 * DIFFICULTY[config.difficulty].moveSpeed,
jumpPower: 760,
onGround: true,
ducking: false
@@ -101,11 +162,10 @@ function createInitialState() {
};
}
-let state = createInitialState();
+let state = createInitialState(gameConfig);
let saveEligible = false;
let scoreSavedThisRun = false;
let saveInFlight = false;
-
const saveScoreBtnEl = saveScoreFormEl.querySelector('button[type="submit"]');
function setSaveFormBusy(isBusy) {
@@ -125,14 +185,39 @@ window.addEventListener('keydown', (e) => {
});
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 = {
+ vipMode: vipModeEl.checked,
+ 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 = '';
+});
+
restartBtnEl.addEventListener('click', () => {
- state = createInitialState();
+ state = createInitialState(gameConfig);
keys.clear();
deathScreenEl.classList.add('hidden');
saveScoreFormEl.classList.add('hidden');
saveEligible = false;
scoreSavedThisRun = false;
setSaveFormBusy(false);
+ state.running = true;
});
saveScoreFormEl.addEventListener('submit', async (e) => {
@@ -147,7 +232,6 @@ saveScoreFormEl.addEventListener('submit', async (e) => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, score: state.score })
});
-
if (!res.ok) throw new Error('Score save failed');
saveEligible = false;
@@ -162,17 +246,44 @@ saveScoreFormEl.addEventListener('submit', async (e) => {
});
function drawBackground() {
- ctx.fillStyle = '#8ddcff';
+ const camCenter = state.cameraX + canvas.width / 2;
+ const biome = getBiomeAt(camCenter);
+
+ ctx.fillStyle = biome.sky;
ctx.fillRect(0, 0, canvas.width, canvas.height);
- ctx.fillStyle = '#88d34f';
+ ctx.fillStyle = biome.ground;
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
+ // Water and lava pits
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);
}
+
+ 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);
+ ctx.fillText(gameConfig.vipMode ? 'VIP MODE' : 'STANDARD', 18, 122);
}
function drawTRex(p) {
@@ -192,8 +303,46 @@ function drawAnts() {
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 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);
}
}
@@ -203,8 +352,6 @@ function drawPterodactyls() {
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);
}
}
@@ -220,7 +367,6 @@ function drawMeteors() {
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;
@@ -228,7 +374,6 @@ function updateFloatingTexts(dt) {
}
state.floatingTexts = state.floatingTexts.filter((t) => t.ttl > 0);
}
-
function drawFloatingTexts() {
ctx.save();
ctx.font = 'bold 22px Arial';
@@ -246,7 +391,7 @@ const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y <
function updateHeartsUI() {
const active = '❤'.repeat(state.hearts);
- const lost = '❤'.repeat(Math.max(0, 3 - state.hearts));
+ const lost = '❤'.repeat(Math.max(0, state.maxHearts - state.hearts));
heartsEl.innerHTML = active + lost;
}
@@ -274,8 +419,7 @@ async function kill(reason) {
state.running = false;
state.dead = true;
state.deadReason = reason;
-
- finalScoreEl.textContent = `Final Score: ${state.score}`;
+ finalScoreEl.textContent = `Final Score: ${state.score} • ${reason}`;
await populateLeaderboard();
deathScreenEl.classList.remove('hidden');
}
@@ -293,6 +437,11 @@ function isOverWater(player) {
return 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;
@@ -301,7 +450,7 @@ function updateAnts(dt) {
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 (ant.x > 5100) ant.vx = -Math.abs(ant.vx);
if (intersects(p, ant)) {
const playerBottom = p.y + p.height;
@@ -319,30 +468,65 @@ function updateAnts(dt) {
}
}
-const PTERODACTYL_LANES = [
- // low lane: standing dino collides, ducking dino should pass underneath
- { y: groundY - 80, height: 28 }
-];
+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
- };
+ 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
+ height: player.height - topInset - 2
};
}
@@ -351,16 +535,11 @@ function updatePterodactyls(dt) {
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
- });
+ 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 = 2.2;
+ state.pteroSpawnTimer = DIFFICULTY[gameConfig.difficulty].pteroSpawn;
}
const p = state.player;
@@ -378,20 +557,18 @@ function updateMeteors(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 });
+ state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420 * DIFFICULTY[gameConfig.difficulty].hazardSpeed, landed: false });
playSfx('meteor_spawn');
- state.meteorSpawnTimer = 1.5;
+ 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;
@@ -409,6 +586,7 @@ function updatePlayer(dt) {
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;
@@ -436,6 +614,7 @@ function updatePlayer(dt) {
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;
}
@@ -451,6 +630,10 @@ function tick(ts) {
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
updatePlayer(dt);
updateAnts(dt);
+ updateCactus();
+ updateMonkeys(dt);
+ updateSabertooths(dt);
+ updateMammoths();
updatePterodactyls(dt);
updateMeteors(dt);
updateFloatingTexts(dt);
@@ -459,6 +642,9 @@ function tick(ts) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground();
drawAnts();
+ drawMonkeys();
+ drawSabertooths();
+ drawMammoths();
drawPterodactyls();
drawMeteors();
drawFloatingTexts();
diff --git a/styles.css b/styles.css
index 18e9cec..635fb36 100644
--- a/styles.css
+++ b/styles.css
@@ -25,7 +25,8 @@ body {
border: 2px solid #1b3a47;
background: #b5ecff;
}
-#deathScreen {
+#deathScreen,
+#startScreen {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
@@ -40,10 +41,29 @@ body {
width: min(92vw, 460px);
}
.hidden { display: none !important; }
-button, input {
+button, input, select {
font-size: 16px;
padding: 8px 10px;
margin-top: 8px;
+ width: 100%;
+}
+label {
+ display: block;
+ margin-top: 10px;
+ font-weight: 700;
+}
+.inline-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.inline-option input {
+ width: auto;
+ margin: 0;
+}
+.error {
+ color: #c62828;
+ min-height: 20px;
}
ol { padding-left: 22px; }
@@ -51,5 +71,5 @@ ol { padding-left: 22px; }
display: inline-block;
margin-right: 2px;
}
-.heart-active { color: #e02424; }
+.heart-active { color: #f2c94c; }
.heart-lost { color: #111; }