Compare commits

..

No commits in common. "d8742ec5bbf743cae145f2393ff19ba2c9c0e40c" and "6a57b9dcfab23c7210dbcd6050400dd5446ef422" have entirely different histories.

5 changed files with 19 additions and 555 deletions

View File

@ -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.

View File

@ -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">

View File

@ -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);
}); });

View File

@ -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;

View File

@ -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
```