Compare commits

..
13 Commits
6 changed files with 690 additions and 21 deletions
+23 -1
View File
@@ -16,6 +16,11 @@ LAN test URL (from another device on your network):
`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()"` `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`) 2. Open: `http://<YOUR_LAN_IP>:8000` (example: `http://192.168.1.25:8000`)
## USB / thumbdrive distribution
Need to run Dino Land from a USB drive on unknown machines (Windows/macOS/ChromeOS)?
See: **[thumbdrive-install.md](./thumbdrive-install.md)**
## Controls ## Controls
- **Up Arrow**: jump - **Up Arrow**: jump
@@ -25,7 +30,8 @@ LAN test URL (from another device on your network):
## Gameplay Rules ## Gameplay Rules
- TRex starts with **3 hearts**. - TRex starts with **5 hearts** (or **10 hearts** in VIP mode).
- Easy and Medium end with a portal that advances to the next difficulty while preserving score.
- Moving forward earns score (**+1 per forward step unit**). - Moving forward earns score (**+1 per forward step unit**).
- Water gaps cause instant death if landed in. - Water gaps cause instant death if landed in.
- Ant collision costs 1 heart; jumping on ants crushes them. - Ant collision costs 1 heart; jumping on ants crushes them.
@@ -38,6 +44,18 @@ LAN test URL (from another device on your network):
- Backend endpoint: `api/scores.php` - Backend endpoint: `api/scores.php`
- Data file: `data/scores.json` - Data file: `data/scores.json`
## Audio / music toggle behavior
- Music defaults to **OFF** when no preference exists in localStorage.
- Toggle preference key: `dinoLand.musicEnabled` (`'1'` on, `'0'` off).
- First user click on the music toggle resumes/unlocks Web Audio and starts procedural music when turned ON.
- Music state is context-aware:
- **Title/start screen** uses title music profile.
- **Gameplay** switches profile by biome (Plains, Desert, Jungle, Snow, and Lava mapped to the Volcano profile).
- **Death screen** switches to death music profile.
- **Restart/respawn to start screen** returns to title profile.
- Turning music OFF performs a short fade to mute; turning back ON fades in the current state profile.
## Quick verification checklist ## Quick verification checklist
1. Start server and open game. 1. Start server and open game.
@@ -45,3 +63,7 @@ LAN test URL (from another device on your network):
3. Confirm hearts drop on ant / pterodactyl / meteor collisions. 3. Confirm hearts drop on ant / pterodactyl / meteor collisions.
4. Confirm water causes immediate game over. 4. Confirm water causes immediate game over.
5. Confirm leaderboard loads and score save works. 5. Confirm leaderboard loads and score save works.
6. With a fresh browser profile, confirm music button starts as `🔇 Music: Off`.
7. Click toggle to ON and confirm audible looping background music starts.
8. Click toggle to OFF and confirm music fades/mutes.
9. Reload page and confirm toggle state persists.
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+17 -8
View File
@@ -13,9 +13,17 @@
</div> </div>
<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>
<button id="musicToggle" type="button" aria-pressed="false" aria-label="Toggle music">🔇 Music: Off</button>
<div id="startScreen"> <div id="startScreen">
<div class="panel"> <div class="panel">
<img
class="start-logo"
src="assets/images/dinoland-logo.png"
alt="Dino Land logo"
width="320"
height="180"
/>
<h1>Start Dino Land</h1> <h1>Start Dino Land</h1>
<form id="startForm"> <form id="startForm">
<label for="startPassword">Password</label> <label for="startPassword">Password</label>
@@ -23,18 +31,19 @@
<label for="accessMode">Access Mode</label> <label for="accessMode">Access Mode</label>
<select id="accessMode"> <select id="accessMode">
<option value="standard" selected>Standard (3 hearts)</option> <option value="standard" selected>Standard (5 hearts)</option>
<option value="vip">VIP (6 hearts)</option> <option value="vip">VIP (10 hearts)</option>
<option value="vipPlus">VIP+ (9 hearts)</option>
</select> </select>
<label for="difficulty">Difficulty</label> <div style="display: none; !important;">
<select id="difficulty"> <label for="difficulty" style="display: none; !important;">Difficulty</label>
<option value="easy">Easy</option> <select id="difficulty" style="display: none; !important;">
<option value="medium" selected>Medium</option> <option value="easy" selected>Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option> <option value="hard">Hard</option>
</select> </select>
</div>
<p id="startError" class="error"></p> <p id="startError" class="error"></p>
<button type="submit">Start Game</button> <button type="submit">Start Game</button>
</form> </form>
+425 -12
View File
@@ -8,6 +8,36 @@ const leaderboardEl = document.getElementById('leaderboard');
const saveScoreFormEl = document.getElementById('saveScoreForm'); const saveScoreFormEl = document.getElementById('saveScoreForm');
const playerNameEl = document.getElementById('playerName'); const playerNameEl = document.getElementById('playerName');
const restartBtnEl = document.getElementById('restartBtn'); 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 startScreenEl = document.getElementById('startScreen');
const startFormEl = document.getElementById('startForm'); const startFormEl = document.getElementById('startForm');
@@ -62,12 +92,216 @@ const BIOMES = [
let audioCtx = null; let audioCtx = null;
let gameConfig = { accessMode: 'standard', difficulty: 'medium' }; 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() { function ensureAudioCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume(); 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) { function playSfx(type) {
if (!musicEnabled) return;
try { try {
ensureAudioCtx(); ensureAudioCtx();
const now = audioCtx.currentTime; const now = audioCtx.currentTime;
@@ -83,6 +317,7 @@ function playSfx(type) {
ptero_spawn: { f0: 520, f1: 460, dur: 0.1, wave: 'sawtooth', 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_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 }, meteor_land: { f0: 140, f1: 70, dur: 0.18, wave: 'square', vol: 0.08 },
portal_fade: { f0: 240, f1: 480, dur: 0.28, wave: 'triangle', vol: 0.05 },
hit: { f0: 260, f1: 140, dur: 0.1, wave: 'square', vol: 0.06 } hit: { f0: 260, f1: 140, dur: 0.1, wave: 'square', vol: 0.06 }
}; };
@@ -98,7 +333,7 @@ function playSfx(type) {
} catch (_) {} } catch (_) {}
} }
const waterGaps = [ const baseWaterGaps = [
{ x: 700, width: 130 }, { x: 700, width: 130 },
{ x: 1300, width: 150 }, { x: 1300, width: 150 },
{ x: 2100, width: 170 }, { x: 2100, width: 170 },
@@ -143,9 +378,19 @@ function getBiomeAt(x) {
} }
function getMaxHearts(accessMode) { function getMaxHearts(accessMode) {
if (accessMode === 'vipPlus') return 9; if (accessMode === 'vip') return 10;
if (accessMode === 'vip') return 6; return 5;
return 3; }
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) { function createInitialState(config = gameConfig) {
@@ -162,6 +407,22 @@ function createInitialState(config = gameConfig) {
maxHearts, maxHearts,
hitCooldown: 0, hitCooldown: 0,
floatingTexts: [], floatingTexts: [],
worldCleared: false,
portal: {
active: false,
visible: false,
entered: false,
fadeAlpha: 0,
fadeInSfxPlayed: 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: [ 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 },
@@ -187,7 +448,8 @@ function createInitialState(config = gameConfig) {
speed: 300 * DIFFICULTY[config.difficulty].moveSpeed, speed: 300 * DIFFICULTY[config.difficulty].moveSpeed,
jumpPower: 760, jumpPower: 760,
onGround: true, onGround: true,
ducking: false ducking: false,
damageFlashTimer: 0
} }
}; };
} }
@@ -215,11 +477,30 @@ window.addEventListener('keydown', (e) => {
}); });
window.addEventListener('keyup', (e) => keys.delete(e.key)); 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) => { startFormEl.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const pass = passwordEl.value.trim(); const pass = passwordEl.value.trim();
if (pass !== '1027') { if (pass !== '1027') {
startErrorEl.textContent = 'Wrong password. Try 1027.'; startErrorEl.textContent = 'Wrong password. Ask Tanner.';
return; return;
} }
@@ -237,6 +518,13 @@ startFormEl.addEventListener('submit', (e) => {
deathScreenEl.classList.add('hidden'); deathScreenEl.classList.add('hidden');
state.running = true; state.running = true;
startErrorEl.textContent = ''; startErrorEl.textContent = '';
updateMusicForGameState(true);
if (musicEnabled) {
resumeAudioContext().then((resumed) => {
if (resumed) setMusicEnabledAudio(true);
});
}
}); });
function restartToStartScreen() { function restartToStartScreen() {
@@ -254,6 +542,7 @@ function restartToStartScreen() {
passwordEl.value = ''; passwordEl.value = '';
startErrorEl.textContent = ''; startErrorEl.textContent = '';
startScreenEl.classList.remove('hidden'); startScreenEl.classList.remove('hidden');
updateMusicForGameState(true);
} }
restartBtnEl.addEventListener('click', restartToStartScreen); restartBtnEl.addEventListener('click', restartToStartScreen);
@@ -326,7 +615,7 @@ function drawBackground() {
ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY); ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY);
// Water and lava pits // Water and lava pits
for (const gap of waterGaps) { for (const gap of state.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';
@@ -354,15 +643,19 @@ function drawBackground() {
ctx.font = 'bold 20px Arial'; ctx.font = 'bold 20px Arial';
ctx.fillText(`Biome: ${biome.name}`, 18, 70); ctx.fillText(`Biome: ${biome.name}`, 18, 70);
ctx.fillText(`Difficulty: ${gameConfig.difficulty.toUpperCase()}`, 18, 96); ctx.fillText(`Difficulty: ${gameConfig.difficulty.toUpperCase()}`, 18, 96);
const modeLabel = gameConfig.accessMode === 'vipPlus' ? 'VIP+ MODE' : gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD'; const modeLabel = gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD MODE';
ctx.fillText(modeLabel, 18, 122); ctx.fillText(modeLabel, 18, 122);
} }
function drawTRex(p) { function drawTRex(p) {
const sx = p.x - state.cameraX; const sx = p.x - state.cameraX;
ctx.fillStyle = '#35535f'; const isDamageFlashing = p.damageFlashTimer > 0;
const bodyColor = isDamageFlashing ? '#d94b4b' : '#35535f';
const detailColor = isDamageFlashing ? '#a52828' : '#27404a';
ctx.fillStyle = bodyColor;
ctx.fillRect(sx, p.y, p.width, p.height); ctx.fillRect(sx, p.y, p.width, p.height);
ctx.fillStyle = '#27404a'; ctx.fillStyle = detailColor;
ctx.fillRect(sx + p.width - 12, p.y + 14, 7, 7); 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 + 10, p.y + p.height - 8, 14, 8);
ctx.fillRect(sx + p.width - 24, p.y + p.height - 8, 14, 8); ctx.fillRect(sx + p.width - 24, p.y + p.height - 8, 14, 8);
@@ -436,6 +729,118 @@ function drawMeteors() {
} }
} }
function drawPortal() {
if (!state.portal.visible || state.portal.fadeAlpha <= 0.01) 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 = portal.fadeAlpha;
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);
ctx.restore();
}
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) {
const portal = state.portal;
portal.pulse += dt;
const nextDifficulty = getNextDifficulty(gameConfig.difficulty);
if (!nextDifficulty) {
portal.active = false;
portal.visible = false;
portal.fadeAlpha = Math.max(0, portal.fadeAlpha - dt * 3.2);
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, portal.x - p.width - 24);
// Reveal portal much earlier, while keeping its fixed world position.
const portalRevealX = Math.max(0, portal.x - canvas.width * 1.6);
portal.visible = p.x >= portalRevealX;
portal.active = p.x >= portalActivationX;
if (portal.visible) {
portal.fadeAlpha = Math.min(1, portal.fadeAlpha + dt * 2.8);
if (!portal.fadeInSfxPlayed) {
playSfx('portal_fade');
portal.fadeInSfxPlayed = true;
}
} else {
portal.fadeAlpha = Math.max(0, portal.fadeAlpha - dt * 3.2);
portal.fadeInSfxPlayed = false;
}
if (portal.active) {
state.worldCleared = p.x >= worldEndX;
}
if (!portal.active || portal.entered || portal.fadeAlpha < 0.9) return;
const portalHitbox = {
x: portal.x,
y: portal.y,
width: portal.width,
height: portal.height
};
if (intersects(p, portalHitbox)) {
portal.entered = true;
addFloatingText(portal.x + portal.width / 2, portal.y - 14, `${nextDifficulty.toUpperCase()}!`, '#9fe6ff');
resetWorldForDifficulty(nextDifficulty);
}
}
function addFloatingText(x, y, text, color = '#ffe066') { function addFloatingText(x, y, text, color = '#ffe066') {
state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 }); state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 });
} }
@@ -491,6 +896,7 @@ async function kill(reason) {
state.running = false; state.running = false;
state.dead = true; state.dead = true;
state.deadReason = reason; state.deadReason = reason;
updateMusicForGameState(true);
finalScoreEl.textContent = `Final Score: ${state.score}${reason}`; finalScoreEl.textContent = `Final Score: ${state.score}${reason}`;
await populateLeaderboard(); await populateLeaderboard();
deathScreenEl.classList.remove('hidden'); deathScreenEl.classList.remove('hidden');
@@ -500,13 +906,14 @@ 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 = DIFFICULTY[gameConfig.difficulty].hitCooldown; state.hitCooldown = DIFFICULTY[gameConfig.difficulty].hitCooldown;
state.player.damageFlashTimer = 0.18;
playSfx('hit'); playSfx('hit');
if (state.hearts <= 0) kill(reason); if (state.hearts <= 0) kill(reason);
} }
function isOverWater(player) { function isOverWater(player) {
const centerX = player.x + player.width / 2; const centerX = player.x + player.width / 2;
return waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width); return state.waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width);
} }
function isOverLava(player) { function isOverLava(player) {
@@ -698,9 +1105,13 @@ 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;
updateMusicForGameState();
if (state.running) { if (state.running) {
state.hitCooldown = Math.max(0, state.hitCooldown - dt); state.hitCooldown = Math.max(0, state.hitCooldown - dt);
state.player.damageFlashTimer = Math.max(0, state.player.damageFlashTimer - dt);
updatePlayer(dt); updatePlayer(dt);
updatePortal(dt);
updateAnts(dt); updateAnts(dt);
updateCactus(); updateCactus();
updateMonkeys(dt); updateMonkeys(dt);
@@ -719,6 +1130,7 @@ function tick(ts) {
drawMammoths(); drawMammoths();
drawPterodactyls(); drawPterodactyls();
drawMeteors(); drawMeteors();
drawPortal();
drawFloatingTexts(); drawFloatingTexts();
drawTRex(state.player); drawTRex(state.player);
@@ -731,5 +1143,6 @@ function tick(ts) {
requestAnimationFrame((ts) => { requestAnimationFrame((ts) => {
state.lastTs = ts; state.lastTs = ts;
updateHeartsUI(); updateHeartsUI();
updateMusicForGameState(true);
requestAnimationFrame(tick); requestAnimationFrame(tick);
}); });
+25
View File
@@ -25,6 +25,23 @@ body {
border: 2px solid #1b3a47; border: 2px solid #1b3a47;
background: #b5ecff; background: #b5ecff;
} }
#musicToggle {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9;
width: auto;
min-width: 132px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.24);
background: rgba(0,0,0,0.65);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
#deathScreen, #deathScreen,
#startScreen { #startScreen {
position: fixed; position: fixed;
@@ -40,6 +57,14 @@ body {
padding: 20px; padding: 20px;
width: min(92vw, 460px); width: min(92vw, 460px);
} }
.start-logo {
display: block;
width: min(100%, 320px);
height: auto;
margin: 0 auto 8px;
}
.hidden { display: none !important; } .hidden { display: none !important; }
button, input, select { button, input, select {
font-size: 16px; font-size: 16px;
+200
View File
@@ -0,0 +1,200 @@
# Dino Land USB / Thumbdrive Install & Run Guide
For non-technical users running Dino Land from a USB drive on unknown computers (Windows, macOS, ChromeOS).
---
## 1) Quick decision tree
```text
Start
├─ Can you run a local web server (PHP or portable server) from USB?
│ ├─ Yes → Use FULL LOCAL MODE (leaderboard + API should work)
│ └─ No
│ ├─ Can you at least open files in a browser from USB?
│ │ ├─ Yes → Use OFFLINE QUICK PLAY (gameplay only, no score save)
│ │ └─ No → Host machine is locked down; ask for a machine with browser file access
└─ If security warning appears (SmartScreen/Gatekeeper), use browser-only mode or approved machine
```
---
## 2) Recommendation matrix
| Mode | Needs install/admin? | Works from USB only? | Scoreboard/API | Best when |
|---|---|---|---|---|
| **Works with no install** (open file in browser) | No | Yes | **No** (PHP API unavailable) | Locked-down machines, schools, kiosks |
| **Works with portable binaries** (USB-contained server) | Usually No (if executable allowed) | Yes | **Yes** (if local server starts) | Unknown machine, no admin, but executables allowed |
| **Requires host runtime/install** (host PHP/Python/etc.) | Often Yes (or preinstalled tools) | Not strictly | **Yes** | Managed machine with approved dev/runtime tools |
---
## 3) Packaging options to put on USB
## Option A: **Offline quick play** (no scores)
Use existing project files as-is.
Required on USB:
- `index.php` (or optional static fallback page if you create one)
- `js/`, `assets/`, `styles.css`
What works:
- Core gameplay usually works in browser file mode (`file://...`)
What does **not** work reliably:
- Leaderboard save/load via `api/scores.php`
## Option B: **Full local mode** (with local server)
Put full project folder on USB, including:
- `api/`
- `data/`
- all game assets/files
Then run a local server (portable or host runtime) and open `http://127.0.0.1:PORT`.
---
## 4) Per-OS run steps
## Windows
### A) No install (quick play)
1. Insert USB.
2. Open USB in File Explorer.
3. Right-click `index.php` -> **Open with** -> Chrome/Edge.
- If this opens raw PHP text or broken page, use Full Local Mode.
### B) Portable binaries (preferred if allowed)
If your USB has `php\php.exe` bundled:
```bat
cd /d "%~dp0"
php\php.exe -S 127.0.0.1:8000
```
Then open:
- `http://127.0.0.1:8000`
If SmartScreen appears:
- Click **More info** -> **Run anyway** only if USB is trusted.
- On locked corporate machines, this option may be blocked.
### C) Host runtime already installed
In Command Prompt (inside Dino Land folder):
```bat
php -S 127.0.0.1:8000
```
---
## macOS
### A) No install (quick play)
1. Insert USB.
2. In Finder, open Dino Land folder.
3. Drag `index.php` into Chrome/Safari (or File -> Open File).
### B) Portable binary from USB
If USB includes a PHP binary (example path `./php/bin/php`):
```bash
cd "/Volumes/<USB_NAME>/Dino Land"
./php/bin/php -S 127.0.0.1:8000
```
Open `http://127.0.0.1:8000`
If Gatekeeper blocks the binary:
- Right-click binary -> **Open** (one-time allow), or
- If policy blocks unknown binaries, use quick play mode.
If quarantine flag blocks execution, advanced users can remove it:
```bash
xattr -dr com.apple.quarantine "/Volumes/<USB_NAME>/Dino Land/php"
```
(Only do this for trusted files.)
### C) Host runtime (if preinstalled)
```bash
cd "/Volumes/<USB_NAME>/Dino Land"
php -S 127.0.0.1:8000
```
---
## ChromeOS
### A) No install (quick play)
- Open Files app -> USB -> game files.
- Open with browser.
- If browser blocks local JS/file access behavior, use another machine or Linux mode.
### B) Portable/server mode on ChromeOS
- Usually restricted unless Linux (Crostini) is enabled.
- If Linux terminal is available:
```bash
cd "/mnt/chromeos/removable/<USB_NAME>/Dino Land"
php -S 127.0.0.1:8000
```
Then open `http://127.0.0.1:8000` in Chrome.
### C) Host runtime/install required
- On managed school/work Chromebooks, installing/enabling Linux may be disabled by policy.
- In that case, only quick play (if allowed) is feasible.
---
## 5) Limitations, risks, and behavior to expect
- **`file://` origin restrictions:** browsers may block some fetch/XHR/module behaviors from local files.
- **Scoreboard/API dependency:** leaderboard save/load needs `api/scores.php` via server-side PHP. No running PHP server = no persistent scores.
- **Windows SmartScreen:** may warn/block unsigned portable executables from USB.
- **macOS Gatekeeper/quarantine:** may block downloaded/unsigned binaries on USB until manually allowed.
- **ChromeOS constraints:** managed devices often block runtime installs, local servers, or executable permissions.
- **USB write permissions:** if USB is read-only or restricted, `data/scores.json` cannot update even with server running.
---
## 6) Practical fallback order (recommended)
1. Try **Full local mode** (portable PHP on USB).
2. If blocked, try **host-installed PHP** (if already present).
3. If still blocked, use **Offline quick play** (no scores).
---
## 7) Minimal launcher scripts (optional, put on USB)
### `start-windows.bat`
```bat
@echo off
cd /d "%~dp0"
if exist php\php.exe (
php\php.exe -S 127.0.0.1:8000
) else (
php -S 127.0.0.1:8000
)
```
### `start-macos.sh`
```bash
#!/usr/bin/env bash
cd "$(dirname "$0")"
if [ -x "./php/bin/php" ]; then
./php/bin/php -S 127.0.0.1:8000
else
php -S 127.0.0.1:8000
fi
```
Make executable (once):
```bash
chmod +x start-macos.sh
```