540 lines
16 KiB
HTML
540 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>Lunar Lander</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; touch-action: none; font-family: Arial, sans-serif; }
|
|
canvas { display: block; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="game"></canvas>
|
|
<script>
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
let audioCtx = null;
|
|
let musicEnabled = true;
|
|
let sfxEnabled = true;
|
|
let musicPlaying = false;
|
|
|
|
function initAudio() {
|
|
if (!audioCtx) audioCtx = new AudioContext();
|
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
|
}
|
|
|
|
// Atmospheric space melody
|
|
const melody = [
|
|
{n: 220, d: 0.8}, {n: 262, d: 0.8}, {n: 294, d: 0.8}, {n: 330, d: 1.2},
|
|
{n: 294, d: 0.8}, {n: 262, d: 0.8}, {n: 220, d: 1.2},
|
|
{n: 196, d: 0.8}, {n: 220, d: 0.8}, {n: 262, d: 0.8}, {n: 294, d: 1.2}, {n: 0, d: 0.8}
|
|
];
|
|
|
|
const bass = [
|
|
{n: 55, d: 1.6}, {n: 65, d: 1.6}, {n: 73, d: 1.6}, {n: 65, d: 1.6}
|
|
];
|
|
|
|
let melodyIdx = 0, bassIdx = 0, nextMelody = 0, nextBass = 0;
|
|
|
|
function playTone(freq, dur, type = 'triangle', vol = 0.06) {
|
|
if (!audioCtx || !musicEnabled || freq === 0) return;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.value = freq;
|
|
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + dur * 0.9);
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + dur);
|
|
}
|
|
|
|
function playSfx(type) {
|
|
if (!audioCtx || !sfxEnabled) return;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
if (type === 'thrust') {
|
|
osc.frequency.value = 80 + Math.random() * 20;
|
|
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1);
|
|
osc.type = 'sawtooth';
|
|
} else if (type === 'land') {
|
|
osc.frequency.setValueAtTime(400, audioCtx.currentTime);
|
|
osc.frequency.setValueAtTime(600, audioCtx.currentTime + 0.1);
|
|
osc.frequency.setValueAtTime(800, audioCtx.currentTime + 0.2);
|
|
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
|
|
} else if (type === 'crash') {
|
|
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
|
|
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.5);
|
|
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5);
|
|
osc.type = 'sawtooth';
|
|
}
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
osc.start();
|
|
osc.stop(audioCtx.currentTime + 0.6);
|
|
}
|
|
|
|
function updateMusic() {
|
|
if (!audioCtx || !musicEnabled || !musicPlaying) return;
|
|
const now = audioCtx.currentTime;
|
|
if (now >= nextMelody) {
|
|
const note = melody[melodyIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.05);
|
|
nextMelody = now + note.d;
|
|
melodyIdx = (melodyIdx + 1) % melody.length;
|
|
}
|
|
if (now >= nextBass) {
|
|
const note = bass[bassIdx];
|
|
playTone(note.n, note.d, 'triangle', 0.08);
|
|
nextBass = now + note.d;
|
|
bassIdx = (bassIdx + 1) % bass.length;
|
|
}
|
|
}
|
|
|
|
function startMusic() {
|
|
if (!musicPlaying && musicEnabled) {
|
|
initAudio();
|
|
musicPlaying = true;
|
|
nextMelody = audioCtx.currentTime;
|
|
nextBass = audioCtx.currentTime;
|
|
}
|
|
}
|
|
|
|
const canvas = document.getElementById('game');
|
|
const ctx = canvas.getContext('2d');
|
|
let W, H, safeTop, safeBottom;
|
|
|
|
function resize() {
|
|
W = window.innerWidth;
|
|
H = window.innerHeight;
|
|
canvas.width = W;
|
|
canvas.height = H;
|
|
safeTop = H * 0.1;
|
|
safeBottom = H * 0.9;
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
let gameRunning = false;
|
|
let gameOver = false;
|
|
let landed = false;
|
|
let score = 0;
|
|
let level = 1;
|
|
let fuel = 100;
|
|
|
|
const lander = { x: 0, y: 0, w: 40, h: 50, vx: 0, vy: 0, angle: 0, thrusting: false };
|
|
const gravity = 0.02; // Very low gravity for easy landing
|
|
const thrust = 0.08;
|
|
const rotSpeed = 0.05;
|
|
|
|
let terrain = [];
|
|
let landingPads = [];
|
|
let stars = [];
|
|
let particles = [];
|
|
|
|
let keys = { left: false, right: false, up: false };
|
|
let touchThrust = false;
|
|
|
|
function generateTerrain() {
|
|
terrain = [];
|
|
landingPads = [];
|
|
stars = [];
|
|
|
|
// Generate stars
|
|
for (let i = 0; i < 100; i++) {
|
|
stars.push({
|
|
x: Math.random() * W,
|
|
y: safeTop + Math.random() * (safeBottom - safeTop - 100)
|
|
});
|
|
}
|
|
|
|
// Generate terrain
|
|
const segments = 20;
|
|
const segWidth = W / segments;
|
|
let y = safeBottom - 50;
|
|
|
|
for (let i = 0; i <= segments; i++) {
|
|
terrain.push({ x: i * segWidth, y });
|
|
// Add variation
|
|
if (i < segments) {
|
|
const variation = Math.random() * 40 - 20;
|
|
y = Math.max(safeBottom - 150, Math.min(safeBottom - 30, y + variation));
|
|
}
|
|
}
|
|
|
|
// Create landing pads (flat areas)
|
|
const padCount = 2 + Math.min(level, 3);
|
|
const usedIndices = new Set();
|
|
|
|
for (let p = 0; p < padCount; p++) {
|
|
let idx;
|
|
do {
|
|
idx = 2 + Math.floor(Math.random() * (segments - 4));
|
|
} while (usedIndices.has(idx) || usedIndices.has(idx - 1) || usedIndices.has(idx + 1));
|
|
|
|
usedIndices.add(idx);
|
|
|
|
// Flatten area for pad
|
|
const padY = terrain[idx].y;
|
|
terrain[idx].y = padY;
|
|
terrain[idx + 1].y = padY;
|
|
|
|
landingPads.push({
|
|
x: idx * segWidth,
|
|
y: padY,
|
|
w: segWidth,
|
|
multiplier: p === 0 ? 1 : (2 + p) // Smaller pads = more points
|
|
});
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
lander.x = W / 2 - lander.w / 2;
|
|
lander.y = safeTop + 50;
|
|
lander.vx = 0;
|
|
lander.vy = 0;
|
|
lander.angle = 0;
|
|
lander.thrusting = false;
|
|
fuel = 100;
|
|
particles = [];
|
|
generateTerrain();
|
|
gameOver = false;
|
|
landed = false;
|
|
gameRunning = true;
|
|
startMusic();
|
|
}
|
|
|
|
function nextLevel() {
|
|
level++;
|
|
init();
|
|
}
|
|
|
|
function addParticles(x, y, color, count = 5) {
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const spd = 1 + Math.random() * 2;
|
|
particles.push({
|
|
x, y,
|
|
dx: Math.cos(angle) * spd,
|
|
dy: Math.sin(angle) * spd,
|
|
life: 20 + Math.random() * 10,
|
|
color
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', e => {
|
|
initAudio();
|
|
if (e.key === 'ArrowLeft' || e.key === 'a') keys.left = true;
|
|
if (e.key === 'ArrowRight' || e.key === 'd') keys.right = true;
|
|
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === ' ') { keys.up = true; e.preventDefault(); }
|
|
if ((e.key === ' ' || e.key === 'Enter') && gameOver) {
|
|
if (landed) nextLevel();
|
|
else init();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keyup', e => {
|
|
if (e.key === 'ArrowLeft' || e.key === 'a') keys.left = false;
|
|
if (e.key === 'ArrowRight' || e.key === 'd') keys.right = false;
|
|
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === ' ') keys.up = false;
|
|
});
|
|
|
|
canvas.addEventListener('click', e => {
|
|
initAudio();
|
|
if (gameOver) {
|
|
if (landed) nextLevel();
|
|
else init();
|
|
return;
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
initAudio();
|
|
if (gameOver) {
|
|
if (landed) nextLevel();
|
|
else init();
|
|
return;
|
|
}
|
|
const x = e.touches[0].clientX;
|
|
if (x < W / 3) keys.left = true;
|
|
else if (x > W * 2 / 3) keys.right = true;
|
|
else touchThrust = true;
|
|
});
|
|
|
|
canvas.addEventListener('touchend', e => {
|
|
keys.left = false;
|
|
keys.right = false;
|
|
touchThrust = false;
|
|
});
|
|
|
|
canvas.addEventListener('touchmove', e => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
function update() {
|
|
if (!gameRunning || gameOver) return;
|
|
|
|
// Rotation
|
|
if (keys.left) lander.angle -= rotSpeed;
|
|
if (keys.right) lander.angle += rotSpeed;
|
|
|
|
// Thrust
|
|
lander.thrusting = (keys.up || touchThrust) && fuel > 0;
|
|
if (lander.thrusting) {
|
|
lander.vx += Math.sin(lander.angle) * thrust;
|
|
lander.vy -= Math.cos(lander.angle) * thrust;
|
|
fuel -= 0.3;
|
|
if (Math.random() < 0.3) {
|
|
addParticles(
|
|
lander.x + lander.w / 2 - Math.sin(lander.angle) * 25,
|
|
lander.y + lander.h / 2 + Math.cos(lander.angle) * 25,
|
|
'#ff8800', 2
|
|
);
|
|
playSfx('thrust');
|
|
}
|
|
}
|
|
|
|
// Gravity
|
|
lander.vy += gravity;
|
|
|
|
// Move
|
|
lander.x += lander.vx;
|
|
lander.y += lander.vy;
|
|
|
|
// Wrap horizontally
|
|
if (lander.x < -lander.w) lander.x = W;
|
|
if (lander.x > W) lander.x = -lander.w;
|
|
|
|
// Check landing/crash
|
|
const bottomY = lander.y + lander.h;
|
|
|
|
// Check landing pads
|
|
for (const pad of landingPads) {
|
|
if (lander.x + lander.w > pad.x && lander.x < pad.x + pad.w) {
|
|
if (bottomY >= pad.y) {
|
|
// Check for safe landing
|
|
const speed = Math.sqrt(lander.vx * lander.vx + lander.vy * lander.vy);
|
|
const angleDeg = Math.abs(lander.angle * 180 / Math.PI);
|
|
|
|
if (speed < 1.5 && angleDeg < 20) { // Very generous for kids
|
|
// Safe landing!
|
|
landed = true;
|
|
gameOver = true;
|
|
score += Math.floor((100 + fuel) * pad.multiplier);
|
|
playSfx('land');
|
|
} else {
|
|
// Crash
|
|
gameOver = true;
|
|
addParticles(lander.x + lander.w / 2, lander.y + lander.h / 2, '#ff4444', 20);
|
|
playSfx('crash');
|
|
}
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check terrain collision
|
|
for (let i = 0; i < terrain.length - 1; i++) {
|
|
const t1 = terrain[i];
|
|
const t2 = terrain[i + 1];
|
|
|
|
if (lander.x + lander.w > t1.x && lander.x < t2.x) {
|
|
// Interpolate terrain height
|
|
const t = (lander.x + lander.w / 2 - t1.x) / (t2.x - t1.x);
|
|
const terrainY = t1.y + (t2.y - t1.y) * t;
|
|
|
|
if (bottomY >= terrainY) {
|
|
// Crash on terrain
|
|
gameOver = true;
|
|
gameRunning = false;
|
|
musicPlaying = false;
|
|
addParticles(lander.x + lander.w / 2, lander.y + lander.h / 2, '#ff4444', 20);
|
|
playSfx('crash');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update particles
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
const p = particles[i];
|
|
p.x += p.dx;
|
|
p.y += p.dy;
|
|
p.life--;
|
|
if (p.life <= 0) particles.splice(i, 1);
|
|
}
|
|
|
|
updateMusic();
|
|
}
|
|
|
|
function draw() {
|
|
// Background
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Safe zones
|
|
ctx.fillStyle = '#111';
|
|
ctx.fillRect(0, 0, W, safeTop);
|
|
ctx.fillRect(0, safeBottom, W, H - safeBottom);
|
|
|
|
// Stars
|
|
ctx.fillStyle = '#fff';
|
|
for (const star of stars) {
|
|
ctx.fillRect(star.x, star.y, 2, 2);
|
|
}
|
|
|
|
// Header
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 20px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Score: ${score}`, 20, 35);
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`Level ${level}`, W / 2, 35);
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(`Fuel: ${Math.floor(fuel)}%`, W - 20, 35);
|
|
|
|
// Terrain
|
|
ctx.fillStyle = '#555';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, H);
|
|
for (const t of terrain) {
|
|
ctx.lineTo(t.x, t.y);
|
|
}
|
|
ctx.lineTo(W, H);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Landing pads
|
|
ctx.fillStyle = '#4ade80';
|
|
for (const pad of landingPads) {
|
|
ctx.fillRect(pad.x, pad.y - 5, pad.w, 5);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '12px Arial';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(`x${pad.multiplier}`, pad.x + pad.w / 2, pad.y - 10);
|
|
ctx.fillStyle = '#4ade80';
|
|
}
|
|
|
|
// Particles
|
|
for (const p of particles) {
|
|
ctx.fillStyle = p.color;
|
|
ctx.globalAlpha = p.life / 30;
|
|
ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Lander
|
|
ctx.save();
|
|
ctx.translate(lander.x + lander.w / 2, lander.y + lander.h / 2);
|
|
ctx.rotate(lander.angle);
|
|
|
|
// Body
|
|
ctx.fillStyle = '#ddd';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -lander.h / 2);
|
|
ctx.lineTo(-lander.w / 2, lander.h / 2);
|
|
ctx.lineTo(lander.w / 2, lander.h / 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Legs
|
|
ctx.strokeStyle = '#888';
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-lander.w / 2 + 5, lander.h / 2);
|
|
ctx.lineTo(-lander.w / 2 - 5, lander.h / 2 + 10);
|
|
ctx.moveTo(lander.w / 2 - 5, lander.h / 2);
|
|
ctx.lineTo(lander.w / 2 + 5, lander.h / 2 + 10);
|
|
ctx.stroke();
|
|
|
|
// Thrust flame
|
|
if (lander.thrusting) {
|
|
ctx.fillStyle = '#ff8800';
|
|
ctx.beginPath();
|
|
ctx.moveTo(-10, lander.h / 2);
|
|
ctx.lineTo(10, lander.h / 2);
|
|
ctx.lineTo(0, lander.h / 2 + 20 + Math.random() * 10);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// Speed indicator
|
|
const speed = Math.sqrt(lander.vx * lander.vx + lander.vy * lander.vy);
|
|
ctx.fillStyle = speed < 1.5 ? '#4ade80' : '#ef4444';
|
|
ctx.font = '16px Arial';
|
|
ctx.textAlign = 'left';
|
|
ctx.fillText(`Speed: ${speed.toFixed(1)}`, 20, safeBottom + 25);
|
|
|
|
// Controls hint
|
|
ctx.fillStyle = '#666';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Left/Right: Rotate | Up/Tap center: Thrust', W / 2, safeBottom + 25);
|
|
|
|
if (gameOver) {
|
|
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.font = 'bold 36px Arial';
|
|
ctx.textAlign = 'center';
|
|
|
|
if (landed) {
|
|
ctx.fillStyle = '#4ade80';
|
|
ctx.fillText('PERFECT LANDING!', W / 2, H / 2 - 40);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '24px Arial';
|
|
ctx.fillText(`Score: ${score}`, W / 2, H / 2 + 10);
|
|
|
|
ctx.fillStyle = '#4ade80';
|
|
ctx.beginPath();
|
|
ctx.roundRect(W / 2 - 100, H / 2 + 40, 200, 50, 10);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#000';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.fillText('NEXT LEVEL', W / 2, H / 2 + 72);
|
|
} else {
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.fillText('CRASH!', W / 2, H / 2 - 40);
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = '24px Arial';
|
|
ctx.fillText('Land slower and level', W / 2, H / 2 + 10);
|
|
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.roundRect(W / 2 - 100, H / 2 + 40, 200, 50, 10);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 18px Arial';
|
|
ctx.fillText('TRY AGAIN', W / 2, H / 2 + 72);
|
|
}
|
|
}
|
|
}
|
|
|
|
function gameLoop() {
|
|
update();
|
|
draw();
|
|
requestAnimationFrame(gameLoop);
|
|
}
|
|
|
|
window.gameScore = 0;
|
|
Object.defineProperty(window, 'gameScore', { get: () => score });
|
|
window.pauseGame = () => { gameRunning = false; musicPlaying = false; };
|
|
window.resumeGame = () => { if (!gameOver) { gameRunning = true; startMusic(); } };
|
|
window.setMusicEnabled = (v) => { musicEnabled = v; if (!v) musicPlaying = false; };
|
|
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
|
|
|
init();
|
|
gameLoop();
|
|
</script>
|
|
</body>
|
|
</html>
|