Compare commits
No commits in common. "d8742ec5bbf743cae145f2393ff19ba2c9c0e40c" and "6a57b9dcfab23c7210dbcd6050400dd5446ef422" have entirely different histories.
d8742ec5bb
...
6a57b9dcfa
21
README.md
21
README.md
@ -16,11 +16,6 @@ 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
|
||||||
@ -44,18 +39,6 @@ See: **[thumbdrive-install.md](./thumbdrive-install.md)**
|
|||||||
- 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.
|
||||||
@ -63,7 +46,3 @@ See: **[thumbdrive-install.md](./thumbdrive-install.md)**
|
|||||||
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.
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
</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">
|
||||||
|
|||||||
335
js/game.js
335
js/game.js
@ -8,36 +8,6 @@ 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');
|
||||||
@ -92,216 +62,12 @@ 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)();
|
||||||
return audioCtx;
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
@ -317,7 +83,6 @@ 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 }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -410,10 +175,7 @@ function createInitialState(config = gameConfig) {
|
|||||||
worldCleared: false,
|
worldCleared: false,
|
||||||
portal: {
|
portal: {
|
||||||
active: false,
|
active: false,
|
||||||
visible: false,
|
|
||||||
entered: false,
|
entered: false,
|
||||||
fadeAlpha: 0,
|
|
||||||
fadeInSfxPlayed: false,
|
|
||||||
// Keep portal close to the world edge so forward motion naturally carries
|
// Keep portal close to the world edge so forward motion naturally carries
|
||||||
// the player into it once progression is unlocked.
|
// the player into it once progression is unlocked.
|
||||||
x: worldWidth - 60,
|
x: worldWidth - 60,
|
||||||
@ -448,8 +210,7 @@ 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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -477,25 +238,6 @@ 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();
|
||||||
@ -518,13 +260,6 @@ 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() {
|
||||||
@ -542,7 +277,6 @@ 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);
|
||||||
@ -649,13 +383,9 @@ function drawBackground() {
|
|||||||
|
|
||||||
function drawTRex(p) {
|
function drawTRex(p) {
|
||||||
const sx = p.x - state.cameraX;
|
const sx = p.x - state.cameraX;
|
||||||
const isDamageFlashing = p.damageFlashTimer > 0;
|
ctx.fillStyle = '#35535f';
|
||||||
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 = detailColor;
|
ctx.fillStyle = '#27404a';
|
||||||
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);
|
||||||
@ -730,7 +460,7 @@ function drawMeteors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawPortal() {
|
function drawPortal() {
|
||||||
if (!state.portal.visible || state.portal.fadeAlpha <= 0.01) return;
|
if (!state.portal.active) return;
|
||||||
|
|
||||||
const portal = state.portal;
|
const portal = state.portal;
|
||||||
const sx = portal.x - state.cameraX;
|
const sx = portal.x - state.cameraX;
|
||||||
@ -741,10 +471,7 @@ function drawPortal() {
|
|||||||
const glowHeight = portal.height + 16;
|
const glowHeight = portal.height + 16;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = portal.fadeAlpha;
|
ctx.globalAlpha = 0.2 + pulse * 0.2;
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha *= 0.2 + pulse * 0.2;
|
|
||||||
ctx.fillStyle = '#84c7ff';
|
ctx.fillStyle = '#84c7ff';
|
||||||
ctx.fillRect(sx - 10, portal.y - 8, glowWidth, glowHeight);
|
ctx.fillRect(sx - 10, portal.y - 8, glowWidth, glowHeight);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@ -768,7 +495,6 @@ function drawPortal() {
|
|||||||
ctx.fillStyle = '#0f0f0f';
|
ctx.fillStyle = '#0f0f0f';
|
||||||
ctx.font = 'bold 14px Arial';
|
ctx.font = 'bold 14px Arial';
|
||||||
ctx.fillText('PORTAL', sx - 8, portal.y - 10);
|
ctx.fillText('PORTAL', sx - 8, portal.y - 10);
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWorldForDifficulty(nextDifficulty) {
|
function resetWorldForDifficulty(nextDifficulty) {
|
||||||
@ -788,14 +514,11 @@ function resetWorldForDifficulty(nextDifficulty) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updatePortal(dt) {
|
function updatePortal(dt) {
|
||||||
const portal = state.portal;
|
state.portal.pulse += dt;
|
||||||
portal.pulse += dt;
|
|
||||||
|
|
||||||
const nextDifficulty = getNextDifficulty(gameConfig.difficulty);
|
const nextDifficulty = getNextDifficulty(gameConfig.difficulty);
|
||||||
if (!nextDifficulty) {
|
if (!nextDifficulty) {
|
||||||
portal.active = false;
|
state.portal.active = false;
|
||||||
portal.visible = false;
|
|
||||||
portal.fadeAlpha = Math.max(0, portal.fadeAlpha - dt * 3.2);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -803,40 +526,26 @@ function updatePortal(dt) {
|
|||||||
const worldEndX = worldWidth - p.width - 8;
|
const worldEndX = worldWidth - p.width - 8;
|
||||||
// Activate slightly before the hard clamp at world end so the player can
|
// Activate slightly before the hard clamp at world end so the player can
|
||||||
// continue moving right and enter the portal without reversing direction.
|
// continue moving right and enter the portal without reversing direction.
|
||||||
const portalActivationX = Math.max(0, portal.x - p.width - 24);
|
const portalActivationX = Math.max(0, state.portal.x - p.width - 24);
|
||||||
// Reveal portal much earlier, while keeping its fixed world position.
|
const reachedProgressionPoint = p.x >= portalActivationX;
|
||||||
const portalRevealX = Math.max(0, portal.x - canvas.width * 1.6);
|
|
||||||
|
|
||||||
portal.visible = p.x >= portalRevealX;
|
if (reachedProgressionPoint) {
|
||||||
portal.active = p.x >= portalActivationX;
|
state.portal.active = true;
|
||||||
|
|
||||||
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;
|
state.worldCleared = p.x >= worldEndX;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!portal.active || portal.entered || portal.fadeAlpha < 0.9) return;
|
if (!state.portal.active || state.portal.entered) return;
|
||||||
|
|
||||||
const portalHitbox = {
|
const portalHitbox = {
|
||||||
x: portal.x,
|
x: state.portal.x,
|
||||||
y: portal.y,
|
y: state.portal.y,
|
||||||
width: portal.width,
|
width: state.portal.width,
|
||||||
height: portal.height
|
height: state.portal.height
|
||||||
};
|
};
|
||||||
|
|
||||||
if (intersects(p, portalHitbox)) {
|
if (intersects(p, portalHitbox)) {
|
||||||
portal.entered = true;
|
state.portal.entered = true;
|
||||||
addFloatingText(portal.x + portal.width / 2, portal.y - 14, `${nextDifficulty.toUpperCase()}!`, '#9fe6ff');
|
addFloatingText(state.portal.x + state.portal.width / 2, state.portal.y - 14, `${nextDifficulty.toUpperCase()}!`, '#9fe6ff');
|
||||||
resetWorldForDifficulty(nextDifficulty);
|
resetWorldForDifficulty(nextDifficulty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -896,7 +605,6 @@ 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');
|
||||||
@ -906,7 +614,6 @@ 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);
|
||||||
}
|
}
|
||||||
@ -1105,11 +812,8 @@ 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);
|
updatePortal(dt);
|
||||||
updateAnts(dt);
|
updateAnts(dt);
|
||||||
@ -1143,6 +847,5 @@ function tick(ts) {
|
|||||||
requestAnimationFrame((ts) => {
|
requestAnimationFrame((ts) => {
|
||||||
state.lastTs = ts;
|
state.lastTs = ts;
|
||||||
updateHeartsUI();
|
updateHeartsUI();
|
||||||
updateMusicForGameState(true);
|
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
});
|
});
|
||||||
|
|||||||
17
styles.css
17
styles.css
@ -25,23 +25,6 @@ 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;
|
||||||
|
|||||||
@ -1,200 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
Loading…
x
Reference in New Issue
Block a user