Compare commits
10 Commits
a3e49dffb9
...
85e2bbb48b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e2bbb48b | ||
|
|
e106d8ea65 | ||
|
|
be519d515e | ||
|
|
a848a6854b | ||
|
|
df3f1e513e | ||
|
|
20f4f4fbc2 | ||
|
|
d433141ccb | ||
|
|
4470266234 | ||
|
|
b68f71beb2 | ||
|
|
38ec8bb1ef |
47
README.md
Normal file
47
README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Dino Land
|
||||||
|
|
||||||
|
No-build browser game using plain **HTML/CSS/JS/PHP**.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/Users/lawsonawhittington/.openclaw/workspace/Dino Land"
|
||||||
|
php -S 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Open on the same machine: http://localhost:8000
|
||||||
|
|
||||||
|
LAN test URL (from another device on your network):
|
||||||
|
1. Find your LAN IP:
|
||||||
|
`python3 -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0]);s.close()"`
|
||||||
|
2. Open: `http://<YOUR_LAN_IP>:8000` (example: `http://192.168.1.25:8000`)
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Up Arrow**: jump
|
||||||
|
- **Down Arrow**: duck
|
||||||
|
- **Left Arrow**: walk backward
|
||||||
|
- **Right Arrow**: walk forward
|
||||||
|
|
||||||
|
## Gameplay Rules
|
||||||
|
|
||||||
|
- TRex starts with **3 hearts**.
|
||||||
|
- Moving forward earns score (**+1 per forward step unit**).
|
||||||
|
- Water gaps cause instant death if landed in.
|
||||||
|
- Ant collision costs 1 heart; jumping on ants crushes them.
|
||||||
|
- Pterodactyls unlock after score > 100 (duck to avoid).
|
||||||
|
- Meteors unlock after score > 250.
|
||||||
|
- On death: top-10 leaderboard is shown; top-10 runs can be saved.
|
||||||
|
|
||||||
|
## Leaderboard persistence
|
||||||
|
|
||||||
|
- Backend endpoint: `api/scores.php`
|
||||||
|
- Data file: `data/scores.json`
|
||||||
|
|
||||||
|
## Quick verification checklist
|
||||||
|
|
||||||
|
1. Start server and open game.
|
||||||
|
2. Confirm score increases while moving right.
|
||||||
|
3. Confirm hearts drop on ant / pterodactyl / meteor collisions.
|
||||||
|
4. Confirm water causes immediate game over.
|
||||||
|
5. Confirm leaderboard loads and score save works.
|
||||||
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 @@
|
|||||||
|
[]
|
||||||
29
index.php
29
index.php
@ -14,6 +14,33 @@
|
|||||||
|
|
||||||
<canvas id="game" width="960" height="540" aria-label="Dino Land game canvas"></canvas>
|
<canvas id="game" width="960" height="540" aria-label="Dino Land game canvas"></canvas>
|
||||||
|
|
||||||
|
<div id="startScreen">
|
||||||
|
<div class="panel">
|
||||||
|
<h1>Start Dino Land</h1>
|
||||||
|
<form id="startForm">
|
||||||
|
<label for="startPassword">Password</label>
|
||||||
|
<input id="startPassword" type="password" required placeholder="Enter password" />
|
||||||
|
|
||||||
|
<label for="accessMode">Access Mode</label>
|
||||||
|
<select id="accessMode">
|
||||||
|
<option value="standard" selected>Standard (3 hearts)</option>
|
||||||
|
<option value="vip">VIP (6 hearts)</option>
|
||||||
|
<option value="vipPlus">VIP+ (9 hearts)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="difficulty">Difficulty</label>
|
||||||
|
<select id="difficulty">
|
||||||
|
<option value="easy">Easy</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="hard">Hard</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<p id="startError" class="error"></p>
|
||||||
|
<button type="submit">Start Game</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="deathScreen" class="hidden">
|
<div id="deathScreen" class="hidden">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h1>TRex Down!</h1>
|
<h1>TRex Down!</h1>
|
||||||
@ -25,7 +52,7 @@
|
|||||||
<input id="playerName" maxlength="20" required />
|
<input id="playerName" maxlength="20" required />
|
||||||
<button type="submit">Save Score</button>
|
<button type="submit">Save Score</button>
|
||||||
</form>
|
</form>
|
||||||
<button id="restartBtn">Play Again</button>
|
<button id="restartBtn">Restart</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
552
js/game.js
552
js/game.js
@ -2,11 +2,101 @@ const canvas = document.getElementById('game');
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const scoreEl = document.getElementById('score');
|
const scoreEl = document.getElementById('score');
|
||||||
const heartsEl = document.getElementById('hearts');
|
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 keys = new Set();
|
||||||
const gravity = 1900;
|
const gravity = 1900;
|
||||||
const groundY = 420;
|
const groundY = 420;
|
||||||
const worldWidth = 6000;
|
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 waterGaps = [
|
const waterGaps = [
|
||||||
{ x: 700, width: 130 },
|
{ x: 700, width: 130 },
|
||||||
@ -16,68 +106,256 @@ const waterGaps = [
|
|||||||
{ x: 3900, width: 200 }
|
{ x: 3900, width: 200 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const state = {
|
const lavaPuddles = [
|
||||||
running: true,
|
{ 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 === 'vipPlus') return 9;
|
||||||
|
if (accessMode === 'vip') return 6;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialState(config = gameConfig) {
|
||||||
|
const maxHearts = getMaxHearts(config.accessMode);
|
||||||
|
return {
|
||||||
|
running: false,
|
||||||
dead: false,
|
dead: false,
|
||||||
deadReason: '',
|
deadReason: '',
|
||||||
lastTs: performance.now(),
|
lastTs: performance.now(),
|
||||||
score: 0,
|
score: 0,
|
||||||
stepCarry: 0,
|
stepCarry: 0,
|
||||||
cameraX: 0,
|
cameraX: 0,
|
||||||
hearts: 3,
|
hearts: maxHearts,
|
||||||
|
maxHearts,
|
||||||
hitCooldown: 0,
|
hitCooldown: 0,
|
||||||
|
floatingTexts: [],
|
||||||
ants: [
|
ants: [
|
||||||
{ x: 1000, y: groundY - 20, width: 22, height: 20, vx: -65, alive: true },
|
{ 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: 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: 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 }
|
{ 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: [],
|
pterodactyls: [],
|
||||||
pteroSpawnTimer: 0,
|
pteroSpawnTimer: 0,
|
||||||
meteors: [],
|
meteors: [],
|
||||||
meteorSpawnTimer: 0,
|
meteorSpawnTimer: 0,
|
||||||
player: {
|
player: {
|
||||||
x: 160,
|
x: 160,
|
||||||
y: 0,
|
y: groundY - 76,
|
||||||
width: 64,
|
width: 64,
|
||||||
height: 76,
|
height: 76,
|
||||||
standingHeight: 76,
|
standingHeight: 76,
|
||||||
duckHeight: 48,
|
duckHeight: 48,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
vy: 0,
|
vy: 0,
|
||||||
speed: 300,
|
speed: 300 * DIFFICULTY[config.difficulty].moveSpeed,
|
||||||
jumpPower: 760,
|
jumpPower: 760,
|
||||||
onGround: true,
|
onGround: true,
|
||||||
ducking: false
|
ducking: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
state.player.y = groundY - state.player.height;
|
}
|
||||||
|
|
||||||
|
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) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) e.preventDefault();
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
keys.add(e.key);
|
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.vy = -state.player.jumpPower;
|
||||||
state.player.onGround = false;
|
state.player.onGround = false;
|
||||||
|
playSfx('jump');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener('keyup', (e) => keys.delete(e.key));
|
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() {
|
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.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.fillStyle = '#88d34f';
|
drawBiomeParallax(biome);
|
||||||
|
ctx.fillStyle = biome.ground;
|
||||||
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
|
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
|
||||||
|
|
||||||
// Water gaps
|
// Water and lava pits
|
||||||
for (const gap of waterGaps) {
|
for (const gap of waterGaps) {
|
||||||
const sx = gap.x - state.cameraX;
|
const sx = gap.x - state.cameraX;
|
||||||
if (sx + gap.width < 0 || sx > canvas.width) continue;
|
if (sx + gap.width < 0 || sx > canvas.width) continue;
|
||||||
ctx.fillStyle = '#2e8bcf';
|
ctx.fillStyle = '#2e8bcf';
|
||||||
ctx.fillRect(sx, groundY, gap.width, canvas.height - groundY);
|
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 === 'vipPlus' ? 'VIP+ MODE' : gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD';
|
||||||
|
ctx.fillText(modeLabel, 18, 122);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTRex(p) {
|
function drawTRex(p) {
|
||||||
@ -97,8 +375,46 @@ function drawAnts() {
|
|||||||
const sx = ant.x - state.cameraX;
|
const sx = ant.x - state.cameraX;
|
||||||
if (sx + ant.width < 0 || sx > canvas.width) continue;
|
if (sx + ant.width < 0 || sx > canvas.width) continue;
|
||||||
ctx.fillRect(sx, ant.y, ant.width, ant.height);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,8 +424,6 @@ function drawPterodactyls() {
|
|||||||
const sx = p.x - state.cameraX;
|
const sx = p.x - state.cameraX;
|
||||||
if (sx + p.width < 0 || sx > canvas.width) continue;
|
if (sx + p.width < 0 || sx > canvas.width) continue;
|
||||||
ctx.fillRect(sx, p.y, p.width, p.height);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,27 +436,72 @@ function drawMeteors() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function intersects(a, b) {
|
function addFloatingText(x, y, text, color = '#ffe066') {
|
||||||
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;
|
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() {
|
function updateHeartsUI() {
|
||||||
heartsEl.textContent = '❤'.repeat(state.hearts) + '🖤'.repeat(Math.max(0, 3 - state.hearts));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 && !scoreSavedThisRun && !saveInFlight;
|
||||||
|
saveScoreFormEl.classList.toggle('hidden', !saveEligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kill(reason) {
|
||||||
|
if (state.dead) return;
|
||||||
state.running = false;
|
state.running = false;
|
||||||
state.dead = true;
|
state.dead = true;
|
||||||
state.deadReason = reason;
|
state.deadReason = reason;
|
||||||
|
finalScoreEl.textContent = `Final Score: ${state.score} • ${reason}`;
|
||||||
|
await populateLeaderboard();
|
||||||
|
deathScreenEl.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHit(reason) {
|
function applyHit(reason) {
|
||||||
if (state.hitCooldown > 0 || state.dead) return;
|
if (state.hitCooldown > 0 || state.dead) return;
|
||||||
state.hearts -= 1;
|
state.hearts -= 1;
|
||||||
state.hitCooldown = 1.0;
|
state.hitCooldown = DIFFICULTY[gameConfig.difficulty].hitCooldown;
|
||||||
if (state.hearts <= 0) {
|
playSfx('hit');
|
||||||
kill(reason);
|
if (state.hearts <= 0) kill(reason);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOverWater(player) {
|
function isOverWater(player) {
|
||||||
@ -150,16 +509,20 @@ function isOverWater(player) {
|
|||||||
return waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width);
|
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) {
|
function updateAnts(dt) {
|
||||||
const p = state.player;
|
const p = state.player;
|
||||||
const prevBottom = p.y + p.height - p.vy * dt;
|
const prevBottom = p.y + p.height - p.vy * dt;
|
||||||
|
|
||||||
for (const ant of state.ants) {
|
for (const ant of state.ants) {
|
||||||
if (!ant.alive) continue;
|
if (!ant.alive) continue;
|
||||||
|
|
||||||
ant.x += ant.vx * dt;
|
ant.x += ant.vx * dt;
|
||||||
if (ant.x < 200) ant.vx = Math.abs(ant.vx);
|
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)) {
|
if (intersects(p, ant)) {
|
||||||
const playerBottom = p.y + p.height;
|
const playerBottom = p.y + p.height;
|
||||||
@ -167,6 +530,9 @@ function updateAnts(dt) {
|
|||||||
if (stomped) {
|
if (stomped) {
|
||||||
ant.alive = false;
|
ant.alive = false;
|
||||||
p.vy = -380;
|
p.vy = -380;
|
||||||
|
state.score += 50;
|
||||||
|
addFloatingText(ant.x + ant.width / 2, ant.y - 10, '+50', '#ffe066');
|
||||||
|
playSfx('squish');
|
||||||
} else {
|
} else {
|
||||||
applyHit('A swarm of ants got you.');
|
applyHit('A swarm of ants got you.');
|
||||||
}
|
}
|
||||||
@ -174,45 +540,107 @@ function updateAnts(dt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePterodactyls(dt) {
|
function updateCactus() {
|
||||||
if (state.score <= 100) return;
|
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;
|
state.pteroSpawnTimer -= dt;
|
||||||
|
|
||||||
if (state.pteroSpawnTimer <= 0) {
|
if (state.pteroSpawnTimer <= 0) {
|
||||||
const altitude = groundY - (state.player.standingHeight - 24);
|
const lane = PTERODACTYL_LANES[0];
|
||||||
state.pterodactyls.push({
|
const speedMul = DIFFICULTY[gameConfig.difficulty].hazardSpeed;
|
||||||
x: state.cameraX + canvas.width + 80,
|
state.pterodactyls.push({ x: state.cameraX + canvas.width + 80, y: lane.y, width: 54, height: lane.height, vx: -280 * speedMul });
|
||||||
y: altitude,
|
playSfx('ptero_spawn');
|
||||||
width: 54,
|
state.pteroSpawnTimer = DIFFICULTY[gameConfig.difficulty].pteroSpawn;
|
||||||
height: 26,
|
|
||||||
vx: -280
|
|
||||||
});
|
|
||||||
state.pteroSpawnTimer = 2.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const p = state.player;
|
const p = state.player;
|
||||||
|
const playerHitbox = getPlayerHitboxForAirHazards(p);
|
||||||
for (const flyer of state.pterodactyls) {
|
for (const flyer of state.pterodactyls) {
|
||||||
flyer.x += flyer.vx * dt;
|
flyer.x += flyer.vx * dt;
|
||||||
if (intersects(p, flyer)) {
|
if (intersects(playerHitbox, getPterodactylHitbox(flyer))) applyHit('Pterodactyl collision. Duck next time!');
|
||||||
applyHit('Pterodactyl collision. Duck next time!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
state.pterodactyls = state.pterodactyls.filter((f) => f.x + f.width > state.cameraX - 150);
|
state.pterodactyls = state.pterodactyls.filter((f) => f.x + f.width > state.cameraX - 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMeteors(dt) {
|
function updateMeteors(dt) {
|
||||||
if (state.score <= 250) return;
|
if (state.score <= DIFFICULTY[gameConfig.difficulty].meteorUnlock) return;
|
||||||
|
|
||||||
state.meteorSpawnTimer -= dt;
|
state.meteorSpawnTimer -= dt;
|
||||||
|
|
||||||
if (state.meteorSpawnTimer <= 0) {
|
if (state.meteorSpawnTimer <= 0) {
|
||||||
const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160);
|
const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160);
|
||||||
state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420 });
|
state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420 * DIFFICULTY[gameConfig.difficulty].hazardSpeed, landed: false });
|
||||||
state.meteorSpawnTimer = 1.5;
|
playSfx('meteor_spawn');
|
||||||
|
state.meteorSpawnTimer = DIFFICULTY[gameConfig.difficulty].meteorSpawn;
|
||||||
}
|
}
|
||||||
|
|
||||||
const p = state.player;
|
const p = state.player;
|
||||||
for (const m of state.meteors) {
|
for (const m of state.meteors) {
|
||||||
m.y += m.vy * dt;
|
m.y += m.vy * dt;
|
||||||
|
if (!m.landed && m.y + m.height >= groundY) {
|
||||||
|
m.landed = true;
|
||||||
|
playSfx('meteor_land');
|
||||||
|
}
|
||||||
if (intersects(p, m)) {
|
if (intersects(p, m)) {
|
||||||
applyHit('Meteor strike!');
|
applyHit('Meteor strike!');
|
||||||
m.y = canvas.height + 100;
|
m.y = canvas.height + 100;
|
||||||
@ -227,7 +655,10 @@ function updatePlayer(dt) {
|
|||||||
if (keys.has('ArrowLeft')) move -= 1;
|
if (keys.has('ArrowLeft')) move -= 1;
|
||||||
if (keys.has('ArrowRight')) move += 1;
|
if (keys.has('ArrowRight')) move += 1;
|
||||||
|
|
||||||
|
const wasDucking = p.ducking;
|
||||||
p.ducking = keys.has('ArrowDown') && p.onGround;
|
p.ducking = keys.has('ArrowDown') && p.onGround;
|
||||||
|
if (!wasDucking && p.ducking) playSfx('duck');
|
||||||
|
|
||||||
const targetHeight = p.ducking ? p.duckHeight : p.standingHeight;
|
const targetHeight = p.ducking ? p.duckHeight : p.standingHeight;
|
||||||
if (targetHeight !== p.height) {
|
if (targetHeight !== p.height) {
|
||||||
p.y += p.height - targetHeight;
|
p.y += p.height - targetHeight;
|
||||||
@ -254,10 +685,8 @@ function updatePlayer(dt) {
|
|||||||
p.y = groundY - p.height;
|
p.y = groundY - p.height;
|
||||||
p.vy = 0;
|
p.vy = 0;
|
||||||
p.onGround = true;
|
p.onGround = true;
|
||||||
|
if (isOverWater(p)) kill('SPLASH! Water is instant death.');
|
||||||
if (isOverWater(p)) {
|
if (isOverLava(p)) kill('LAVA! Instant death.');
|
||||||
kill('SPLASH! Water is instant death.');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
p.onGround = false;
|
p.onGround = false;
|
||||||
}
|
}
|
||||||
@ -265,17 +694,6 @@ function updatePlayer(dt) {
|
|||||||
state.cameraX = Math.max(0, Math.min(worldWidth - canvas.width, p.x - 240));
|
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) {
|
function tick(ts) {
|
||||||
const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
|
const dt = Math.min((ts - state.lastTs) / 1000, 0.05);
|
||||||
state.lastTs = ts;
|
state.lastTs = ts;
|
||||||
@ -284,17 +702,25 @@ function tick(ts) {
|
|||||||
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
|
state.hitCooldown = Math.max(0, state.hitCooldown - dt);
|
||||||
updatePlayer(dt);
|
updatePlayer(dt);
|
||||||
updateAnts(dt);
|
updateAnts(dt);
|
||||||
|
updateCactus();
|
||||||
|
updateMonkeys(dt);
|
||||||
|
updateSabertooths(dt);
|
||||||
|
updateMammoths();
|
||||||
updatePterodactyls(dt);
|
updatePterodactyls(dt);
|
||||||
updateMeteors(dt);
|
updateMeteors(dt);
|
||||||
|
updateFloatingTexts(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
drawBackground();
|
drawBackground();
|
||||||
drawAnts();
|
drawAnts();
|
||||||
|
drawMonkeys();
|
||||||
|
drawSabertooths();
|
||||||
|
drawMammoths();
|
||||||
drawPterodactyls();
|
drawPterodactyls();
|
||||||
drawMeteors();
|
drawMeteors();
|
||||||
|
drawFloatingTexts();
|
||||||
drawTRex(state.player);
|
drawTRex(state.player);
|
||||||
drawDeathText();
|
|
||||||
|
|
||||||
scoreEl.textContent = `Score: ${state.score}`;
|
scoreEl.textContent = `Score: ${state.score}`;
|
||||||
updateHeartsUI();
|
updateHeartsUI();
|
||||||
|
|||||||
33
styles.css
33
styles.css
@ -25,7 +25,8 @@ body {
|
|||||||
border: 2px solid #1b3a47;
|
border: 2px solid #1b3a47;
|
||||||
background: #b5ecff;
|
background: #b5ecff;
|
||||||
}
|
}
|
||||||
#deathScreen {
|
#deathScreen,
|
||||||
|
#startScreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.65);
|
background: rgba(0,0,0,0.65);
|
||||||
@ -40,9 +41,37 @@ body {
|
|||||||
width: min(92vw, 460px);
|
width: min(92vw, 460px);
|
||||||
}
|
}
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
button, input {
|
button, input, select {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
margin-top: 8px;
|
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; }
|
ol { padding-left: 22px; }
|
||||||
|
|
||||||
|
/* removed unused play-again-row styles */
|
||||||
|
|
||||||
|
.heart {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.heart-active { color: #f2c94c; }
|
||||||
|
.heart-lost { color: #111; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user