Created
June 14, 2026 16:01
-
-
Save ivanfioravanti/f3593754cf80732706ade58786231552 to your computer and use it in GitHub Desktop.
Lunar Lander Prompt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Lunar Lander GLM-5.2</title> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| height: 100%; | |
| background: #05060a; | |
| color: #c8d3e6; | |
| font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 10px; | |
| overflow: hidden; | |
| } | |
| h1 { | |
| font-size: 14px; | |
| letter-spacing: 4px; | |
| color: #6b7a93; | |
| margin: 0; | |
| text-transform: uppercase; | |
| } | |
| #wrap { | |
| position: relative; | |
| width: min(96vw, 96vh * 1.5); | |
| aspect-ratio: 3 / 2; | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| background: #05060a; | |
| border: 1px solid #1c2433; | |
| border-radius: 6px; | |
| image-rendering: crisp-edges; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Lunar Lander GLM-5.2</h1> | |
| <div id="wrap"> | |
| <canvas id="game" width="900" height="600"></canvas> | |
| </div> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const canvas = document.getElementById("game"); | |
| const ctx = canvas.getContext("2d"); | |
| const VIEW = { W: canvas.width, H: canvas.height }; | |
| // ---- Physics constants (per fixed 1/60s step). Tuned for floaty lunar feel. ---- | |
| const GRAVITY = 0.030; // downward acceleration | |
| const THRUST = 0.092; // main engine acceleration (along nose direction) | |
| const ROT_SPEED = 0.045; // radians per step when turning | |
| const FUEL_MAX = 100; | |
| const BURN = 0.18; // fuel per step while thrusting (~9s of full burn) | |
| const SAFE_VY = 1.5; // safe descent speed (vertical) | |
| const SAFE_VX = 0.8; // safe horizontal speed | |
| const TILT_MAX = 0.26; // ~15° landing tolerance | |
| const LEG_SPREAD = 11; // lander foot x-offset (local) | |
| const LEG_LEN = 14; // lander foot y-offset (local) | |
| const DISPLAY_V = 10; // velocity multiplier for friendly "m/s" readout | |
| // ---- Procedural audio (Web Audio API, no external files) ---- | |
| const Sound = (() => { | |
| let ctx = null, master = null, noiseBuffer = null; | |
| let thrustGain = null, lastThrust = false, rcsLast = 0, muted = false; | |
| function init() { | |
| if (ctx) return; | |
| ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| master = ctx.createGain(); | |
| master.gain.value = muted ? 0 : 0.5; | |
| master.connect(ctx.destination); | |
| // Shared white-noise buffer for engine, crash, and RCS. | |
| const len = ctx.sampleRate * 2; | |
| noiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate); | |
| const data = noiseBuffer.getChannelData(0); | |
| for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; | |
| // Continuous looping engine source, gain modulated by thrust. | |
| const src = ctx.createBufferSource(); | |
| src.buffer = noiseBuffer; | |
| src.loop = true; | |
| const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 90; | |
| const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 620; | |
| thrustGain = ctx.createGain(); thrustGain.gain.value = 0; | |
| src.connect(hp); hp.connect(lp); lp.connect(thrustGain); thrustGain.connect(master); | |
| src.start(); | |
| } | |
| function resume() { | |
| if (!ctx) init(); | |
| if (ctx.state === "suspended") ctx.resume(); | |
| } | |
| function setThrust(on) { | |
| if (!ctx || on === lastThrust) return; | |
| lastThrust = on; | |
| const t = ctx.currentTime; | |
| thrustGain.gain.cancelScheduledValues(t); | |
| thrustGain.gain.setTargetAtTime(on ? 0.5 : 0.0, t, 0.04); | |
| } | |
| function land() { | |
| if (!ctx) return; | |
| const t0 = ctx.currentTime; | |
| [523.25, 659.25, 783.99, 1046.5].forEach((f, i) => { // C5 E5 G5 C6 | |
| const t = t0 + i * 0.12; | |
| const osc = ctx.createOscillator(); | |
| osc.type = "triangle"; osc.frequency.value = f; | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(0.0001, t); | |
| g.gain.exponentialRampToValueAtTime(0.4, t + 0.02); | |
| g.gain.exponentialRampToValueAtTime(0.001, t + 0.5); | |
| osc.connect(g); g.connect(master); | |
| osc.start(t); osc.stop(t + 0.55); | |
| }); | |
| } | |
| function crash() { | |
| if (!ctx) return; | |
| const t = ctx.currentTime; | |
| const src = ctx.createBufferSource(); src.buffer = noiseBuffer; | |
| const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; | |
| lp.frequency.setValueAtTime(1400, t); | |
| lp.frequency.exponentialRampToValueAtTime(120, t + 0.6); | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(0.9, t); | |
| g.gain.exponentialRampToValueAtTime(0.001, t + 0.8); | |
| src.connect(lp); lp.connect(g); g.connect(master); | |
| src.start(t); src.stop(t + 0.85); | |
| const osc = ctx.createOscillator(); osc.type = "sine"; | |
| osc.frequency.setValueAtTime(130, t); | |
| osc.frequency.exponentialRampToValueAtTime(38, t + 0.5); | |
| const og = ctx.createGain(); | |
| og.gain.setValueAtTime(0.8, t); | |
| og.gain.exponentialRampToValueAtTime(0.001, t + 0.6); | |
| osc.connect(og); og.connect(master); | |
| osc.start(t); osc.stop(t + 0.65); | |
| } | |
| function rcs() { | |
| if (!ctx) return; | |
| const now = performance.now(); | |
| if (now - rcsLast < 110) return; // throttle the click train | |
| rcsLast = now; | |
| const t = ctx.currentTime; | |
| const src = ctx.createBufferSource(); src.buffer = noiseBuffer; | |
| const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 900; bp.Q.value = 1.2; | |
| const g = ctx.createGain(); | |
| g.gain.setValueAtTime(0.16, t); | |
| g.gain.exponentialRampToValueAtTime(0.001, t + 0.12); | |
| src.connect(bp); bp.connect(g); g.connect(master); | |
| src.start(t); src.stop(t + 0.13); | |
| } | |
| function toggle() { | |
| muted = !muted; | |
| if (master) master.gain.setTargetAtTime(muted ? 0 : 0.5, ctx.currentTime, 0.02); | |
| } | |
| const isMuted = () => muted; | |
| return { resume, setThrust, land, crash, rcs, toggle, isMuted }; | |
| })(); | |
| // ---- Game state ---- | |
| let terrain, lander, particles, stars, state, reason; | |
| const keys = { up: false, left: false, right: false }; | |
| // ---- Helpers ---- | |
| const rand = (a, b) => a + Math.random() * (b - a); | |
| const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); | |
| const normalizeAngle = (a) => { | |
| a = a % (2 * Math.PI); | |
| if (a < -Math.PI) a += 2 * Math.PI; | |
| if (a > Math.PI) a -= 2 * Math.PI; | |
| return a; | |
| }; | |
| // ---- Terrain ---- | |
| function generateTerrain() { | |
| const { W, H } = VIEW; | |
| const padW = 96; | |
| const padCenter = rand(W * 0.22, W * 0.78); | |
| const padX1 = padCenter - padW / 2; | |
| const padX2 = padCenter + padW / 2; | |
| const padY = rand(H * 0.62, H * 0.8); | |
| const pts = []; | |
| // Left jagged edge | |
| let y = rand(H * 0.55, H * 0.78); | |
| let x = 0; | |
| pts.push({ x, y }); | |
| while (x < padX1 - 24) { | |
| x += rand(22, 50); | |
| if (x >= padX1) break; | |
| y = clamp(y + rand(-1, 1) * H * 0.09, H * 0.46, H * 0.88); | |
| pts.push({ x, y }); | |
| } | |
| // Flat landing pad | |
| pts.push({ x: padX1, y: padY }); | |
| pts.push({ x: padX2, y: padY }); | |
| // Right jagged edge | |
| x = padX2; | |
| y = padY; | |
| while (x < W) { | |
| x += rand(22, 50); | |
| if (x > W) x = W; | |
| y = clamp(y + rand(-1, 1) * H * 0.09, H * 0.46, H * 0.88); | |
| pts.push({ x, y }); | |
| if (x >= W) break; | |
| } | |
| if (pts[pts.length - 1].x < W) pts.push({ x: W, y: pts[pts.length - 1].y }); | |
| return { pts, padX1, padX2, padY }; | |
| } | |
| function terrainYAt(x) { | |
| const p = terrain.pts; | |
| if (x <= p[0].x) return p[0].y; | |
| for (let i = 0; i < p.length - 1; i++) { | |
| if (x >= p[i].x && x <= p[i + 1].x) { | |
| const span = p[i + 1].x - p[i].x || 1; | |
| const t = (x - p[i].x) / span; | |
| return p[i].y + (p[i + 1].y - p[i].y) * t; | |
| } | |
| } | |
| return p[p.length - 1].y; | |
| } | |
| // ---- Stars ---- | |
| function makeStars() { | |
| const arr = []; | |
| for (let i = 0; i < 90; i++) { | |
| arr.push({ | |
| x: Math.random() * VIEW.W, | |
| y: Math.random() * VIEW.H * 0.7, | |
| r: Math.random() < 0.85 ? 0.7 : 1.4, | |
| a: rand(0.25, 0.9), | |
| }); | |
| } | |
| return arr; | |
| } | |
| // ---- Lander geometry helpers ---- | |
| function rotateLocal(lx, ly) { | |
| const c = Math.cos(lander.angle), s = Math.sin(lander.angle); | |
| return { x: lander.x + lx * c - ly * s, y: lander.y + lx * s + ly * c }; | |
| } | |
| const footWorld = (side) => rotateLocal(side * LEG_SPREAD, LEG_LEN); | |
| // ---- Reset / restart ---- | |
| function reset() { | |
| Sound.setThrust(false); | |
| terrain = generateTerrain(); | |
| lander = { | |
| x: VIEW.W * 0.5 + rand(-40, 40), | |
| y: 70, | |
| vx: rand(-0.6, 0.6), | |
| vy: 0, | |
| angle: 0, | |
| fuel: FUEL_MAX, | |
| }; | |
| particles = []; | |
| stars = makeStars(); | |
| state = "playing"; | |
| reason = ""; | |
| } | |
| // ---- Input ---- | |
| window.addEventListener("keydown", (e) => { | |
| Sound.resume(); // unlock audio on the first user gesture | |
| if (e.code === "ArrowUp" || e.code === "Space") { keys.up = true; e.preventDefault(); } | |
| else if (e.code === "ArrowLeft") { keys.left = true; e.preventDefault(); } | |
| else if (e.code === "ArrowRight") { keys.right = true; e.preventDefault(); } | |
| else if (e.code === "KeyR") reset(); | |
| else if (e.code === "KeyM") Sound.toggle(); | |
| }); | |
| window.addEventListener("keyup", (e) => { | |
| if (e.code === "ArrowUp" || e.code === "Space") keys.up = false; | |
| else if (e.code === "ArrowLeft") keys.left = false; | |
| else if (e.code === "ArrowRight") keys.right = false; | |
| }); | |
| // ---- Simulation step (fixed 60Hz) ---- | |
| function update() { | |
| if (state !== "playing") return; | |
| if (keys.left) { lander.angle -= ROT_SPEED; Sound.rcs(); } | |
| if (keys.right) { lander.angle += ROT_SPEED; Sound.rcs(); } | |
| const thrusting = keys.up && lander.fuel > 0; | |
| Sound.setThrust(thrusting); | |
| if (thrusting) { | |
| const a = lander.angle; | |
| lander.vx += Math.sin(a) * THRUST; | |
| lander.vy += -Math.cos(a) * THRUST; | |
| lander.fuel = Math.max(0, lander.fuel - BURN); | |
| // Emit exhaust particles from the nozzle. | |
| const nz = rotateLocal(0, 6); | |
| const dirx = Math.sin(a), diry = -Math.cos(a); // nose direction | |
| for (let i = 0; i < 2; i++) { | |
| const spread = rand(-0.5, 0.5); | |
| const sp = rand(1.2, 2.6); | |
| particles.push({ | |
| x: nz.x, | |
| y: nz.y, | |
| vx: (-dirx + spread) * sp + lander.vx * 0.3, | |
| vy: (-diry + spread) * sp + lander.vy * 0.3, | |
| life: rand(16, 30), | |
| max: 30, | |
| }); | |
| } | |
| } | |
| lander.vy += GRAVITY; | |
| lander.x += lander.vx; | |
| lander.y += lander.vy; | |
| // Bounds: bounce softly off walls, stop at ceiling. | |
| if (lander.x < 8) { lander.x = 8; lander.vx *= -0.3; } | |
| if (lander.x > VIEW.W - 8) { lander.x = VIEW.W - 8; lander.vx *= -0.3; } | |
| if (lander.y < 14) { lander.y = 14; if (lander.vy < 0) lander.vy = 0; } | |
| // Particles | |
| for (const p of particles) { | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.vy += 0.01; | |
| p.life -= 1; | |
| } | |
| particles = particles.filter((p) => p.life > 0); | |
| // Collision: check both feet against the terrain below them. | |
| const lf = footWorld(-1), rf = footWorld(1); | |
| if (lf.y >= terrainYAt(lf.x) - 0.5 || rf.y >= terrainYAt(rf.x) - 0.5) { | |
| resolve(lf, rf); | |
| } | |
| } | |
| function resolve(lf, rf) { | |
| const tilt = Math.abs(normalizeAngle(lander.angle)); | |
| const onPad = | |
| lf.x >= terrain.padX1 && lf.x <= terrain.padX2 && | |
| rf.x >= terrain.padX1 && rf.x <= terrain.padX2; | |
| const slow = Math.abs(lander.vy) <= SAFE_VY && Math.abs(lander.vx) <= SAFE_VX; | |
| const upright = tilt <= TILT_MAX; | |
| if (onPad && upright && slow) { | |
| state = "landed"; | |
| reason = "The Eagle has landed."; | |
| Sound.setThrust(false); | |
| Sound.land(); | |
| } else { | |
| state = "crashed"; | |
| if (!onPad) reason = "You missed the landing pad."; | |
| else if (!upright) reason = "Touched down at a bad angle."; | |
| else reason = "Came down too hard."; | |
| Sound.setThrust(false); | |
| Sound.crash(); | |
| } | |
| } | |
| // ---- Rendering ---- | |
| function drawBackground() { | |
| ctx.fillStyle = "#05060a"; | |
| ctx.fillRect(0, 0, VIEW.W, VIEW.H); | |
| for (const s of stars) { | |
| ctx.globalAlpha = s.a; | |
| ctx.fillStyle = "#cfd8ff"; | |
| ctx.fillRect(s.x, s.y, s.r, s.r); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| function drawTerrain() { | |
| const { pts, padX1, padX2, padY } = terrain; | |
| // Filled body | |
| ctx.beginPath(); | |
| ctx.moveTo(0, VIEW.H); | |
| for (const p of pts) ctx.lineTo(p.x, p.y); | |
| ctx.lineTo(VIEW.W, VIEW.H); | |
| ctx.closePath(); | |
| const grad = ctx.createLinearGradient(0, VIEW.H * 0.45, 0, VIEW.H); | |
| grad.addColorStop(0, "#222a37"); | |
| grad.addColorStop(1, "#11161f"); | |
| ctx.fillStyle = grad; | |
| ctx.fill(); | |
| // Crisp ridge line | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0].x, pts[0].y); | |
| for (const p of pts) ctx.lineTo(p.x, p.y); | |
| ctx.strokeStyle = "#465369"; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Landing pad (distinct color + markings) | |
| ctx.strokeStyle = "#00e5a0"; | |
| ctx.lineWidth = 5; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(padX1, padY); | |
| ctx.lineTo(padX2, padY); | |
| ctx.stroke(); | |
| // Pad end beacons | |
| ctx.fillStyle = "#00e5a0"; | |
| for (const ex of [padX1, padX2]) { | |
| ctx.fillRect(ex - 2, padY - 14, 4, 14); | |
| ctx.beginPath(); | |
| ctx.arc(ex, padY - 16, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.lineWidth = 1; | |
| ctx.lineCap = "butt"; | |
| } | |
| function drawParticles() { | |
| for (const p of particles) { | |
| const t = p.life / p.max; | |
| ctx.globalAlpha = clamp(t, 0, 1); | |
| ctx.fillStyle = t > 0.6 ? "#ffd24a" : t > 0.3 ? "#ff9d3a" : "#ff5a36"; | |
| const r = 2.4 * t + 0.6; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| function drawLander() { | |
| ctx.save(); | |
| ctx.translate(lander.x, lander.y); | |
| ctx.rotate(lander.angle); | |
| // Engine flame (drawn first, behind body) | |
| if (state === "playing" && keys.up && lander.fuel > 0) { | |
| const flick = rand(0.7, 1.0); | |
| const len = 16 * flick + rand(0, 5); | |
| ctx.beginPath(); | |
| ctx.moveTo(-4, 5); | |
| ctx.lineTo(4, 5); | |
| ctx.lineTo(0, 5 + len); | |
| ctx.closePath(); | |
| const fg = ctx.createLinearGradient(0, 5, 0, 5 + len); | |
| fg.addColorStop(0, "#ffffff"); | |
| fg.addColorStop(0.3, "#ffd24a"); | |
| fg.addColorStop(1, "rgba(255,90,54,0)"); | |
| ctx.fillStyle = fg; | |
| ctx.fill(); | |
| } | |
| // Legs | |
| ctx.strokeStyle = "#9fb0d0"; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(-5, 1); ctx.lineTo(-LEG_SPREAD, LEG_LEN); | |
| ctx.moveTo(5, 1); ctx.lineTo(LEG_SPREAD, LEG_LEN); | |
| ctx.stroke(); | |
| // Feet | |
| ctx.beginPath(); | |
| ctx.moveTo(-LEG_SPREAD - 4, LEG_LEN); ctx.lineTo(-LEG_SPREAD + 4, LEG_LEN); | |
| ctx.moveTo(LEG_SPREAD - 4, LEG_LEN); ctx.lineTo(LEG_SPREAD + 4, LEG_LEN); | |
| ctx.stroke(); | |
| // Body | |
| ctx.fillStyle = "#e8ecf4"; | |
| ctx.beginPath(); | |
| ctx.moveTo(-6, -6); | |
| ctx.lineTo(6, -6); | |
| ctx.lineTo(5, 4); | |
| ctx.lineTo(-5, 4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Nose / capsule | |
| ctx.beginPath(); | |
| ctx.arc(0, -6, 5, Math.PI, 0); | |
| ctx.fillStyle = "#c4cee0"; | |
| ctx.fill(); | |
| // Window | |
| ctx.fillStyle = "#39c5ff"; | |
| ctx.beginPath(); | |
| ctx.arc(0, -5, 2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // Nozzle | |
| ctx.fillStyle = "#7d8aa3"; | |
| ctx.fillRect(-2.5, 4, 5, 3); | |
| ctx.restore(); | |
| } | |
| function drawHUD() { | |
| const lf = footWorld(-1), rf = footWorld(1); | |
| const groundY = Math.max(terrainYAt(lf.x), terrainYAt(rf.x)); | |
| const lowestFoot = Math.max(lf.y, rf.y); | |
| const altitude = Math.max(0, groundY - lowestFoot); | |
| const tiltDeg = normalizeAngle(lander.angle) * 180 / Math.PI; | |
| const vy = lander.vy, vx = lander.vx; | |
| // Panel | |
| ctx.fillStyle = "rgba(10,16,26,0.72)"; | |
| ctx.fillRect(14, 14, 196, 142); | |
| ctx.strokeStyle = "#1e2a3d"; | |
| ctx.lineWidth = 1; | |
| ctx.strokeRect(14, 14, 196, 142); | |
| ctx.font = "12px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.textBaseline = "top"; | |
| const row = (label, val, ok) => { | |
| ctx.fillStyle = "#5e6b82"; | |
| ctx.fillText(label, 24, y); | |
| ctx.fillStyle = ok === undefined ? "#d7e0ef" : ok ? "#50fa7b" : "#ff6b6b"; | |
| ctx.fillText(val, 120, y); | |
| y += 19; | |
| }; | |
| let y = 24; | |
| row("ALT", Math.round(altitude) + " m"); | |
| row("VSPD", (vy * DISPLAY_V).toFixed(1) + " m/s", Math.abs(vy) <= SAFE_VY); | |
| row("HSPD", Math.abs(vx * DISPLAY_V).toFixed(1) + " m/s", Math.abs(vx) <= SAFE_VX); | |
| row("ROT", tiltDeg.toFixed(0) + "°", Math.abs(normalizeAngle(lander.angle)) <= TILT_MAX); | |
| // Fuel gauge | |
| y += 2; | |
| ctx.fillStyle = "#5e6b82"; | |
| ctx.fillText("FUEL " + Math.round(lander.fuel) + "%", 24, y); | |
| y += 15; | |
| const barX = 24, barW = 176, barH = 9; | |
| ctx.fillStyle = "#0c1320"; | |
| ctx.fillRect(barX, y, barW, barH); | |
| const fw = (lander.fuel / FUEL_MAX) * barW; | |
| ctx.fillStyle = lander.fuel > 40 ? "#50fa7b" : lander.fuel > 15 ? "#ffd24a" : "#ff6b6b"; | |
| ctx.fillRect(barX, y, fw, barH); | |
| ctx.strokeStyle = "#1e2a3d"; | |
| ctx.strokeRect(barX, y, barW, barH); | |
| // Audio state indicator (top-right) | |
| ctx.font = "11px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.textAlign = "right"; | |
| ctx.fillStyle = Sound.isMuted() ? "#ff6b6b" : "#465369"; | |
| ctx.fillText((Sound.isMuted() ? "AUDIO OFF" : "AUDIO ON") + " · M", VIEW.W - 16, 22); | |
| // Controls hint | |
| ctx.fillStyle = "#465369"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText("↑ THRUST ← → ROTATE R RESTART M MUTE", VIEW.W / 2, VIEW.H - 22); | |
| ctx.textAlign = "left"; | |
| } | |
| function drawOverlay() { | |
| if (state === "playing") return; | |
| const won = state === "landed"; | |
| ctx.fillStyle = "rgba(5,6,10,0.66)"; | |
| ctx.fillRect(0, 0, VIEW.W, VIEW.H); | |
| ctx.textAlign = "center"; | |
| ctx.textBaseline = "middle"; | |
| ctx.font = "bold 46px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.fillStyle = won ? "#50fa7b" : "#ff6b6b"; | |
| ctx.fillText(won ? "LANDING SUCCESSFUL" : "LANDER DESTROYED", VIEW.W / 2, VIEW.H / 2 - 50); | |
| ctx.font = "15px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.fillStyle = "#c8d3e6"; | |
| ctx.fillText(reason, VIEW.W / 2, VIEW.H / 2 - 6); | |
| // Final telemetry | |
| const tiltDeg = Math.abs(normalizeAngle(lander.angle) * 180 / Math.PI); | |
| ctx.font = "13px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.fillStyle = "#7d8aa3"; | |
| ctx.fillText( | |
| "VSPD " + (Math.abs(lander.vy) * DISPLAY_V).toFixed(1) + " m/s" + | |
| " HSPD " + (Math.abs(lander.vx) * DISPLAY_V).toFixed(1) + " m/s" + | |
| " TILT " + tiltDeg.toFixed(0) + "°", | |
| VIEW.W / 2, VIEW.H / 2 + 28 | |
| ); | |
| ctx.font = "14px 'SF Mono', Menlo, Consolas, monospace"; | |
| ctx.fillStyle = "#d7e0ef"; | |
| ctx.fillText("Press R to restart", VIEW.W / 2, VIEW.H / 2 + 66); | |
| ctx.textAlign = "left"; | |
| ctx.textBaseline = "top"; | |
| } | |
| function render() { | |
| drawBackground(); | |
| drawTerrain(); | |
| drawParticles(); | |
| drawLander(); | |
| drawHUD(); | |
| drawOverlay(); | |
| } | |
| // ---- Main loop with fixed-timestep accumulator ---- | |
| const STEP = 1000 / 60; | |
| let last = performance.now(); | |
| let acc = 0; | |
| function frame(now) { | |
| acc += now - last; | |
| last = now; | |
| if (acc > 200) acc = 200; // avoid spiral of death after tab switches | |
| while (acc >= STEP) { | |
| update(); | |
| acc -= STEP; | |
| } | |
| render(); | |
| requestAnimationFrame(frame); | |
| } | |
| reset(); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lunar Lander K2.7</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| html, body { | |
| width: 100%; height: 100%; | |
| background: #0b0d14; | |
| display: flex; align-items: center; justify-content: center; | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| overflow: hidden; | |
| } | |
| #gameWrap { | |
| position: relative; | |
| box-shadow: 0 0 40px rgba(0,0,0,0.7); | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| canvas { display: block; background: #0b0d14; } | |
| #hud { | |
| position: absolute; top: 0; left: 0; right: 0; | |
| display: grid; grid-template-columns: repeat(5, 1fr); | |
| gap: 8px; | |
| padding: 10px 14px; | |
| font-size: 13px; | |
| font-weight: bold; | |
| color: #a8c6ff; | |
| pointer-events: none; | |
| text-shadow: 0 0 4px rgba(0,0,0,0.8); | |
| } | |
| #hud .label { color: #6d8cc9; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; } | |
| #hud .value { color: #e0ecff; font-size: 15px; font-family: 'Courier New', monospace; } | |
| #hud .value.warn { color: #ff7b72; } | |
| #hud .value.ok { color: #7ee787; } | |
| #fuelBarWrap { | |
| position: absolute; bottom: 12px; left: 14px; right: 14px; | |
| height: 10px; | |
| background: rgba(255,255,255,0.08); | |
| border-radius: 5px; | |
| overflow: hidden; | |
| pointer-events: none; | |
| } | |
| #fuelBar { | |
| width: 100%; height: 100%; | |
| background: linear-gradient(90deg, #ff7b72 0%, #ffd166 50%, #7ee787 100%); | |
| transform-origin: left; | |
| transition: transform 0.05s linear; | |
| } | |
| #overlay { | |
| position: absolute; inset: 0; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| background: rgba(11,13,20,0.88); | |
| color: #fff; text-align: center; | |
| pointer-events: none; | |
| } | |
| #overlay h1 { font-size: 46px; margin-bottom: 12px; letter-spacing: 3px; } | |
| #overlay h1.win { color: #7ee787; text-shadow: 0 0 14px #7ee787; } | |
| #overlay h1.lose { color: #ff7b72; text-shadow: 0 0 14px #ff7b72; } | |
| #overlay p { font-size: 16px; margin: 5px 0; color: #cdd6e6; } | |
| #overlay .key { color: #ffd166; font-weight: bold; } | |
| #overlay.hidden { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="gameWrap"> | |
| <canvas id="game" width="800" height="640"></canvas> | |
| <div id="hud"> | |
| <div><div class="label">Altitude</div><div id="altVal" class="value">0</div></div> | |
| <div><div class="label">H-Speed</div><div id="hspVal" class="value">0</div></div> | |
| <div><div class="label">V-Speed</div><div id="vspVal" class="value">0</div></div> | |
| <div><div class="label">Rotation</div><div id="rotVal" class="value">0°</div></div> | |
| <div><div class="label">Fuel</div><div id="fuelVal" class="value ok">100%</div></div> | |
| </div> | |
| <div id="fuelBarWrap"><div id="fuelBar"></div></div> | |
| <div id="overlay"> | |
| <h1 class="win">LUNAR LANDER K2.7</h1> | |
| <p>Land gently on the <span class="key">green pad</span>.</p> | |
| <p><span class="key">↑</span> thrust <span class="key">←</span> rotate left <span class="key">→</span> rotate right</p> | |
| <p>Stay upright and slow. Press <span class="key">R</span> to restart.</p> | |
| <p style="margin-top:18px; font-size:14px; color:#8996b0;">Press any arrow key to start</p> | |
| </div> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width; | |
| const H = canvas.height; | |
| const altEl = document.getElementById('altVal'); | |
| const hspEl = document.getElementById('hspVal'); | |
| const vspEl = document.getElementById('vspVal'); | |
| const rotEl = document.getElementById('rotVal'); | |
| const fuelEl = document.getElementById('fuelVal'); | |
| const fuelBar = document.getElementById('fuelBar'); | |
| const overlay = document.getElementById('overlay'); | |
| // Physics constants tuned for floaty lunar feel | |
| const GRAVITY = 0.025; | |
| const THRUST = 0.075; | |
| const ROTATION_SPEED = 0.04; | |
| const MAX_SAFE_VSPEED = 1.2; | |
| const MAX_SAFE_HSPEED = 0.8; | |
| const MAX_SAFE_TILT = 12 * (Math.PI / 180); // 12 degrees | |
| const FUEL_MAX = 100; | |
| const FUEL_BURN = 0.18; | |
| let state = 'ready'; // ready | playing | won | crashed | |
| let keys = {}; | |
| let stars = []; | |
| let lastLowFuelBeep = 0; | |
| // ---------- Audio (Web Audio API, no external assets) ---------- | |
| let audioCtx = null; | |
| let thrusterNodes = null; | |
| function initAudio() { | |
| if (audioCtx) return; | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| if (!AC) return; | |
| audioCtx = new AC(); | |
| } | |
| function resumeAudio() { | |
| if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); | |
| } | |
| function makeNoiseBuffer(seconds) { | |
| if (!audioCtx) return null; | |
| const frames = Math.floor(audioCtx.sampleRate * seconds); | |
| const buffer = audioCtx.createBuffer(1, frames, audioCtx.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < frames; i++) { | |
| data[i] = Math.random() * 2 - 1; | |
| } | |
| return buffer; | |
| } | |
| function startThruster() { | |
| if (!audioCtx) initAudio(); | |
| if (!audioCtx || thrusterNodes) return; | |
| const buffer = makeNoiseBuffer(2); | |
| const noise = audioCtx.createBufferSource(); | |
| noise.buffer = buffer; | |
| noise.loop = true; | |
| const filter = audioCtx.createBiquadFilter(); | |
| filter.type = 'bandpass'; | |
| filter.frequency.value = 110; | |
| filter.Q.value = 0.8; | |
| const gain = audioCtx.createGain(); | |
| gain.gain.value = 0; | |
| gain.gain.linearRampToValueAtTime(0.12, audioCtx.currentTime + 0.05); | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| noise.start(); | |
| thrusterNodes = { noise, filter, gain }; | |
| } | |
| function stopThruster() { | |
| if (!thrusterNodes || !audioCtx) return; | |
| const { noise, gain } = thrusterNodes; | |
| gain.gain.cancelScheduledValues(audioCtx.currentTime); | |
| gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.04); | |
| const stopAt = audioCtx.currentTime + 0.08; | |
| noise.stop(stopAt); | |
| setTimeout(() => { | |
| try { noise.disconnect(); } catch (e) {} | |
| }, 120); | |
| thrusterNodes = null; | |
| } | |
| function playTone({ freq = 440, type = 'sine', duration = 0.3, delay = 0, vol = 0.15 }) { | |
| if (!audioCtx) initAudio(); | |
| if (!audioCtx) return; | |
| const t = audioCtx.currentTime + delay; | |
| const osc = audioCtx.createOscillator(); | |
| osc.type = type; | |
| osc.frequency.value = freq; | |
| const gain = audioCtx.createGain(); | |
| gain.gain.setValueAtTime(0, t); | |
| gain.gain.linearRampToValueAtTime(vol, t + 0.02); | |
| gain.gain.exponentialRampToValueAtTime(0.001, t + duration); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(t); | |
| osc.stop(t + duration + 0.05); | |
| } | |
| function playWin() { | |
| // Ascending major arpeggio | |
| [523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => { | |
| playTone({ freq, type: 'sine', duration: 0.45, delay: i * 0.11, vol: 0.18 }); | |
| playTone({ freq: freq * 2, type: 'triangle', duration: 0.35, delay: i * 0.11, vol: 0.04 }); | |
| }); | |
| } | |
| function playCrash() { | |
| if (!audioCtx) initAudio(); | |
| if (!audioCtx) return; | |
| const buffer = makeNoiseBuffer(1.4); | |
| const noise = audioCtx.createBufferSource(); | |
| noise.buffer = buffer; | |
| const filter = audioCtx.createBiquadFilter(); | |
| filter.type = 'lowpass'; | |
| filter.frequency.setValueAtTime(900, audioCtx.currentTime); | |
| filter.frequency.exponentialRampToValueAtTime(40, audioCtx.currentTime + 1.2); | |
| const gain = audioCtx.createGain(); | |
| gain.gain.setValueAtTime(0.4, audioCtx.currentTime); | |
| gain.gain.exponentialRampToValueAtTime(0.005, audioCtx.currentTime + 1.2); | |
| noise.connect(filter); | |
| filter.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| noise.start(); | |
| noise.stop(audioCtx.currentTime + 1.4); | |
| // low rumble underneath | |
| playTone({ freq: 80, type: 'sawtooth', duration: 1.0, vol: 0.12 }); | |
| } | |
| function playLowFuel() { | |
| const now = performance.now(); | |
| if (now - lastLowFuelBeep < 500) return; | |
| lastLowFuelBeep = now; | |
| playTone({ freq: 880, type: 'square', duration: 0.08, vol: 0.08 }); | |
| } | |
| function stopAllSounds() { | |
| stopThruster(); | |
| } | |
| const lander = { | |
| x: W / 2, | |
| y: 80, | |
| vx: 0, | |
| vy: 0, | |
| angle: 0, // 0 = pointing up, radians | |
| fuel: FUEL_MAX, | |
| width: 18, | |
| height: 24 | |
| }; | |
| // Terrain | |
| let terrain = []; | |
| let pad = { x: 0, w: 90 }; | |
| const GROUND_Y = H - 60; | |
| function rand(min, max) { return Math.random() * (max - min) + min; } | |
| function generateStars() { | |
| stars = []; | |
| for (let i = 0; i < 120; i++) { | |
| stars.push({ x: rand(0, W), y: rand(0, GROUND_Y - 30), r: rand(0.5, 2), a: rand(0.3, 1) }); | |
| } | |
| } | |
| function generateTerrain() { | |
| terrain = []; | |
| pad.w = rand(80, 120); | |
| pad.x = rand(W * 0.25, W * 0.75 - pad.w); | |
| const step = 10; | |
| let y = GROUND_Y; | |
| let x = 0; | |
| // Left rough section up to pad | |
| while (x < pad.x) { | |
| terrain.push({ x, y }); | |
| x += step; | |
| y = GROUND_Y + rand(-28, 28); | |
| } | |
| terrain.push({ x: pad.x, y: GROUND_Y }); | |
| // Flat landing pad | |
| const padSteps = Math.ceil(pad.w / step); | |
| for (let i = 0; i <= padSteps; i++) { | |
| terrain.push({ x: pad.x + i * step, y: GROUND_Y }); | |
| } | |
| // Right rough section | |
| x = pad.x + pad.w; | |
| while (x <= W) { | |
| terrain.push({ x, y }); | |
| x += step; | |
| y = GROUND_Y + rand(-28, 28); | |
| } | |
| terrain.push({ x: W, y: GROUND_Y + rand(-28, 28) }); | |
| } | |
| function resetGame() { | |
| stopThruster(); | |
| lander.x = W / 2; | |
| lander.y = 80; | |
| lander.vx = rand(-0.3, 0.3); | |
| lander.vy = rand(-0.1, 0.2); | |
| lander.angle = 0; | |
| lander.fuel = FUEL_MAX; | |
| state = 'playing'; | |
| generateTerrain(); | |
| hideOverlay(); | |
| } | |
| function showOverlay(title, cls, line1, line2) { | |
| overlay.innerHTML = `<h1 class="${cls}">${title}</h1><p>${line1}</p><p>${line2}</p><p style="margin-top:14px; font-size:14px; color:#8996b0;">Press <span class="key">R</span> to restart</p>`; | |
| overlay.classList.remove('hidden'); | |
| } | |
| function hideOverlay() { | |
| overlay.classList.add('hidden'); | |
| } | |
| function normalizeAngle(a) { | |
| a = a % (Math.PI * 2); | |
| if (a > Math.PI) a -= Math.PI * 2; | |
| if (a < -Math.PI) a += Math.PI * 2; | |
| return a; | |
| } | |
| function update() { | |
| if (state !== 'playing') return; | |
| // Rotation | |
| if (keys['arrowleft'] || keys['a']) lander.angle -= ROTATION_SPEED; | |
| if (keys['arrowright'] || keys['d']) lander.angle += ROTATION_SPEED; | |
| lander.angle = normalizeAngle(lander.angle); | |
| // Thrust | |
| const thrusting = (keys['arrowup'] || keys['w']) && lander.fuel > 0; | |
| if (thrusting) { | |
| lander.vx += Math.sin(lander.angle) * THRUST; | |
| lander.vy -= Math.cos(lander.angle) * THRUST; | |
| lander.fuel = Math.max(0, lander.fuel - FUEL_BURN); | |
| startThruster(); | |
| } else { | |
| stopThruster(); | |
| } | |
| // Gravity + integrate | |
| lander.vy += GRAVITY; | |
| lander.x += lander.vx; | |
| lander.y += lander.vy; | |
| // Screen wrap horizontally | |
| if (lander.x < 0) lander.x += W; | |
| if (lander.x > W) lander.x -= W; | |
| // Ceiling | |
| if (lander.y < 0) { lander.y = 0; lander.vy = Math.max(0, lander.vy); } | |
| if (lander.fuel > 0 && lander.fuel < 10) { | |
| playLowFuel(); | |
| } | |
| checkLanding(); | |
| updateHUD(); | |
| } | |
| function terrainYAt(x) { | |
| // Wrap x into terrain range | |
| x = ((x % W) + W) % W; | |
| for (let i = 0; i < terrain.length - 1; i++) { | |
| const p1 = terrain[i]; | |
| const p2 = terrain[i + 1]; | |
| if (x >= p1.x && x <= p2.x) { | |
| const t = (x - p1.x) / (p2.x - p1.x); | |
| return p1.y + (p2.y - p1.y) * t; | |
| } | |
| } | |
| return GROUND_Y; | |
| } | |
| function isOverPad(x) { | |
| const wx = ((x % W) + W) % W; | |
| return wx >= pad.x && wx <= pad.x + pad.w; | |
| } | |
| function checkLanding() { | |
| // Compute the lander's bottom points (left and right foot) | |
| const halfW = lander.width / 2; | |
| const cos = Math.cos(lander.angle); | |
| const sin = Math.sin(lander.angle); | |
| // Feet are at local (±halfW, +height/2) | |
| const feet = [ | |
| { | |
| x: lander.x + ( halfW * cos - lander.height/2 * sin), | |
| y: lander.y + ( halfW * sin + lander.height/2 * cos) | |
| }, | |
| { | |
| x: lander.x + (-halfW * cos - lander.height/2 * sin), | |
| y: lander.y + (-halfW * sin + lander.height/2 * cos) | |
| } | |
| ]; | |
| let lowest = -Infinity; | |
| let lowestX = lander.x; | |
| for (const f of feet) { | |
| const gy = terrainYAt(f.x); | |
| const penetration = gy - f.y; | |
| if (penetration < lowest) { | |
| lowest = penetration; | |
| lowestX = f.x; | |
| } | |
| if (f.y >= gy) { | |
| // Contact | |
| const onPad = isOverPad(f.x); | |
| const tilt = Math.abs(lander.angle); | |
| const speedOK = Math.abs(lander.vy) <= MAX_SAFE_VSPEED && Math.abs(lander.vx) <= MAX_SAFE_HSPEED; | |
| const tiltOK = tilt <= MAX_SAFE_TILT; | |
| stopThruster(); | |
| if (onPad && speedOK && tiltOK) { | |
| state = 'won'; | |
| lander.y += penetration; // sit on ground | |
| lander.vx = 0; | |
| lander.vy = 0; | |
| showOverlay('LANDED', 'win', 'Nice and gentle.', 'Mission accomplished.'); | |
| playWin(); | |
| } else { | |
| state = 'crashed'; | |
| let reason = 'You crashed.'; | |
| if (!onPad) reason = 'That was not the landing pad.'; | |
| else if (!speedOK) reason = 'Too fast for a safe landing.'; | |
| else if (!tiltOK) reason = 'You were not upright enough.'; | |
| showOverlay('CRASHED', 'lose', reason, 'Press R to try again.'); | |
| playCrash(); | |
| } | |
| return; | |
| } | |
| } | |
| } | |
| function drawStars() { | |
| ctx.fillStyle = '#fff'; | |
| for (const s of stars) { | |
| ctx.globalAlpha = s.a; | |
| ctx.beginPath(); | |
| ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| function drawTerrain() { | |
| // Ground body | |
| ctx.beginPath(); | |
| ctx.moveTo(0, H); | |
| for (const p of terrain) ctx.lineTo(p.x, p.y); | |
| ctx.lineTo(W, H); | |
| ctx.closePath(); | |
| ctx.fillStyle = '#161b2a'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#4a5c8a'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Landing pad | |
| ctx.fillStyle = '#2d6a4f'; | |
| ctx.fillRect(pad.x, GROUND_Y - 4, pad.w, 6); | |
| ctx.strokeStyle = '#7ee787'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(pad.x + 2, GROUND_Y - 3, pad.w - 4, 4); | |
| // Pad marker lights | |
| ctx.fillStyle = '#7ee787'; | |
| ctx.beginPath(); | |
| ctx.arc(pad.x + 6, GROUND_Y - 8, 2.5, 0, Math.PI * 2); | |
| ctx.arc(pad.x + pad.w - 6, GROUND_Y - 8, 2.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| function drawLander() { | |
| ctx.save(); | |
| ctx.translate(lander.x, lander.y); | |
| ctx.rotate(lander.angle); | |
| const w = lander.width; | |
| const h = lander.height; | |
| // Main body | |
| ctx.fillStyle = '#e0e0e0'; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -h / 2); | |
| ctx.lineTo(w / 2, h / 2 - 6); | |
| ctx.lineTo(w / 2 - 4, h / 2); | |
| ctx.lineTo(-w / 2 + 4, h / 2); | |
| ctx.lineTo(-w / 2, h / 2 - 6); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#9aa'; | |
| ctx.lineWidth = 1.5; | |
| ctx.stroke(); | |
| // Cockpit window | |
| ctx.fillStyle = '#4cc9f0'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, -2, 5, 7, 0, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#fff'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| // Legs | |
| ctx.strokeStyle = '#bbb'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(-6, 8); ctx.lineTo(-10, 14); ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(6, 8); ctx.lineTo(10, 14); ctx.stroke(); | |
| // Foot pads | |
| ctx.fillStyle = '#888'; | |
| ctx.fillRect(-13, 14, 6, 3); | |
| ctx.fillRect(7, 14, 6, 3); | |
| // Thruster flame | |
| const thrusting = (keys['arrowup'] || keys['w']) && lander.fuel > 0; | |
| if (thrusting && state === 'playing') { | |
| const flicker = rand(0.7, 1.3); | |
| ctx.fillStyle = '#ff9f1c'; | |
| ctx.beginPath(); | |
| ctx.moveTo(-4, h / 2); | |
| ctx.lineTo(4, h / 2); | |
| ctx.lineTo(0, h / 2 + 16 * flicker); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.fillStyle = '#fff'; | |
| ctx.beginPath(); | |
| ctx.moveTo(-2, h / 2); | |
| ctx.lineTo(2, h / 2); | |
| ctx.lineTo(0, h / 2 + 8 * flicker); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawParticles() { | |
| // Optional dust/exhaust could go here; kept simple for now | |
| } | |
| function draw() { | |
| // Sky | |
| const grad = ctx.createLinearGradient(0, 0, 0, H); | |
| grad.addColorStop(0, '#0b0d14'); | |
| grad.addColorStop(1, '#1a1f35'); | |
| ctx.fillStyle = grad; | |
| ctx.fillRect(0, 0, W, H); | |
| drawStars(); | |
| drawTerrain(); | |
| drawLander(); | |
| drawParticles(); | |
| } | |
| function formatSpeed(v) { | |
| return (v >= 0 ? '+' : '') + v.toFixed(2); | |
| } | |
| function updateHUD() { | |
| const groundH = terrainYAt(lander.x); | |
| const altitude = Math.max(0, groundH - (lander.y + lander.height / 2)); | |
| altEl.textContent = altitude.toFixed(1); | |
| hspEl.textContent = formatSpeed(lander.vx); | |
| vspEl.textContent = formatSpeed(lander.vy); | |
| const deg = (lander.angle * 180 / Math.PI).toFixed(1); | |
| rotEl.textContent = deg + '°'; | |
| const fuelPct = (lander.fuel / FUEL_MAX * 100).toFixed(0); | |
| fuelEl.textContent = fuelPct + '%'; | |
| fuelBar.style.transform = `scaleX(${lander.fuel / FUEL_MAX})`; | |
| // Color HUD values based on safety | |
| vspEl.className = 'value' + (Math.abs(lander.vy) > MAX_SAFE_VSPEED ? ' warn' : ' ok'); | |
| hspEl.className = 'value' + (Math.abs(lander.vx) > MAX_SAFE_HSPEED ? ' warn' : ' ok'); | |
| fuelEl.className = 'value' + (lander.fuel < 20 ? ' warn' : ' ok'); | |
| } | |
| function loop() { | |
| update(); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| window.addEventListener('keydown', e => { | |
| initAudio(); | |
| resumeAudio(); | |
| const key = e.key.toLowerCase(); | |
| if (key === 'arrowup' || key === 'arrowdown' || key === 'arrowleft' || key === 'arrowright' || | |
| key === 'w' || key === 'a' || key === 's' || key === 'd') { | |
| e.preventDefault(); | |
| } | |
| keys[key] = true; | |
| if (key === 'r') { | |
| resetGame(); | |
| return; | |
| } | |
| if (state === 'ready' && ['arrowup', 'arrowleft', 'arrowright', 'w', 'a', 'd'].includes(key)) { | |
| resetGame(); | |
| } | |
| }); | |
| window.addEventListener('keyup', e => { | |
| keys[e.key.toLowerCase()] = false; | |
| }); | |
| // Touch controls | |
| let touchStartX = 0, touchStartY = 0; | |
| canvas.addEventListener('touchstart', e => { | |
| initAudio(); | |
| resumeAudio(); | |
| touchStartX = e.touches[0].clientX; | |
| touchStartY = e.touches[0].clientY; | |
| if (state === 'ready') resetGame(); | |
| }, { passive: false }); | |
| canvas.addEventListener('touchmove', e => { | |
| e.preventDefault(); | |
| keys['arrowup'] = false; | |
| keys['arrowleft'] = false; | |
| keys['arrowright'] = false; | |
| const dx = e.touches[0].clientX - touchStartX; | |
| const dy = e.touches[0].clientY - touchStartY; | |
| if (Math.abs(dx) < 20 && Math.abs(dy) < 20) { | |
| keys['arrowup'] = true; | |
| } else if (Math.abs(dy) > Math.abs(dx) && dy < 0) { | |
| keys['arrowup'] = true; | |
| } else if (dx < -20) { | |
| keys['arrowleft'] = true; | |
| } else if (dx > 20) { | |
| keys['arrowright'] = true; | |
| } | |
| }, { passive: false }); | |
| canvas.addEventListener('touchend', e => { | |
| e.preventDefault(); | |
| keys['arrowup'] = false; | |
| keys['arrowleft'] = false; | |
| keys['arrowright'] = false; | |
| }, { passive: false }); | |
| generateStars(); | |
| generateTerrain(); | |
| updateHUD(); | |
| loop(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Lunar Lander M3</title> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| background: #05060a; | |
| color: #e8eef7; | |
| font-family: "Menlo", "Consolas", "Courier New", monospace; | |
| overflow: hidden; | |
| } | |
| .wrap { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| height: 100%; | |
| } | |
| h1 { | |
| margin: 10px 0 6px 0; | |
| font-size: 16px; | |
| letter-spacing: 4px; | |
| color: #aab7c6; | |
| font-weight: normal; | |
| } | |
| canvas { | |
| background: radial-gradient(ellipse at top, #0b1024 0%, #05060a 70%); | |
| box-shadow: 0 0 30px rgba(80, 120, 200, 0.15); | |
| border: 1px solid #1b2336; | |
| } | |
| .hud { | |
| display: flex; | |
| gap: 18px; | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| color: #9fb0c5; | |
| } | |
| .hud span b { | |
| color: #e8eef7; | |
| font-weight: normal; | |
| margin-left: 4px; | |
| } | |
| .hud .ok { color: #6ee7a0; } | |
| .hud .warn { color: #ffcc66; } | |
| .hud .bad { color: #ff6b6b; } | |
| .legend { | |
| font-size: 11px; | |
| color: #6f7d92; | |
| padding: 4px 0 10px 0; | |
| } | |
| .legend kbd { | |
| background: #11151f; | |
| border: 1px solid #2a334a; | |
| padding: 1px 5px; | |
| border-radius: 3px; | |
| color: #cfd8e3; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>LUNAR LANDER</h1> | |
| <div class="hud"> | |
| <span>ALT <b id="hud-alt" class="ok">000</b></span> | |
| <span>VX <b id="hud-vx">0.0</b></span> | |
| <span>VY <b id="hud-vy">0.0</b></span> | |
| <span>FUEL <b id="hud-fuel" class="ok">100</b></span> | |
| <span>ROT <b id="hud-rot">0°</b></span> | |
| <span>STATUS <b id="hud-status" class="ok">FLYING</b></span> | |
| </div> | |
| <canvas id="game" width="900" height="560"></canvas> | |
| <div class="legend"> | |
| <kbd>↑</kbd> main thrust · | |
| <kbd>←</kbd> <kbd>→</kbd> rotate · | |
| <kbd>R</kbd> restart · | |
| <kbd>M</kbd> mute | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const W = canvas.width; | |
| const H = canvas.height; | |
| // ---- Game tuning (lunar / floaty) -------------------------------------- | |
| const GRAVITY = 0.04; // low lunar pull (pixels/frame^2) | |
| const MAIN_THRUST = 0.09; // accel up along lander facing | |
| const SIDE_THRUST = 0.05; // weak RCS for left/right rotation feel | |
| const ROT_SPEED = 0.05; // radians / frame | |
| const FUEL_BURN_MAIN = 0.25; // per frame of main thrust | |
| const FUEL_BURN_RCS = 0.05; // per frame of rotation | |
| const SAFE_VY = 1.8; // |vy| threshold at touchdown | |
| const SAFE_VX = 1.4; // |vx| threshold at touchdown | |
| const SAFE_TILT = 0.25; // |angle| radians (~14 deg) | |
| const START_FUEL = 100; | |
| // ---- State ------------------------------------------------------------- | |
| let lander, particles, terrain, pad, stars, status, message, messageColor; | |
| function reset() { | |
| stopThrustSound(); | |
| lander = { | |
| x: W * 0.5, | |
| y: 80, | |
| vx: (Math.random() - 0.5) * 0.4, | |
| vy: 0, | |
| angle: 0, // 0 = pointing up; +ve = clockwise | |
| w: 22, h: 26, // half-width/height for collision | |
| fuel: START_FUEL, | |
| thrusting: false, | |
| flameScale: 0, | |
| }; | |
| particles = []; | |
| status = 'flying'; // flying | win | crash | |
| message = ''; | |
| messageColor = '#e8eef7'; | |
| // jagged terrain | |
| terrain = []; | |
| const baseY = H - 60; | |
| let x = 0; | |
| const step = 22; | |
| while (x < W) { | |
| // height noise: bias low with occasional peaks | |
| const n = Math.random(); | |
| const h = 10 + n * n * 70; | |
| terrain.push({ x: x, y: baseY - h }); | |
| x += step; | |
| } | |
| terrain.push({ x: W, y: baseY }); | |
| // close polygon to the bottom | |
| terrain.push({ x: W, y: H }); | |
| terrain.push({ x: 0, y: H }); | |
| // pick a flat pad | |
| const padW = 90; | |
| const padStart = 120 + Math.random() * (W - 240); | |
| const padEnd = padStart + padW; | |
| // flatten the segment: average neighbors, force same y | |
| const yVals = []; | |
| for (let i = 0; i < terrain.length; i++) { | |
| const t = terrain[i]; | |
| if (t.x >= padStart && t.x <= padEnd) yVals.push(t.y); | |
| } | |
| const padY = yVals.reduce((a,b)=>a+b,0) / Math.max(1, yVals.length); | |
| for (let i = 0; i < terrain.length; i++) { | |
| const t = terrain[i]; | |
| if (t.x >= padStart && t.x <= padEnd) t.y = padY; | |
| } | |
| pad = { x1: padStart, x2: padEnd, y: padY, w: padW }; | |
| // stars | |
| stars = []; | |
| for (let i = 0; i < 110; i++) { | |
| stars.push({ | |
| x: Math.random() * W, | |
| y: Math.random() * (H * 0.6), | |
| r: Math.random() * 1.1 + 0.2, | |
| tw: Math.random() * Math.PI * 2, | |
| }); | |
| } | |
| } | |
| // ---- Audio (Web Audio API, all synthesized) ---------------------------- | |
| let audioCtx = null; | |
| let masterGain = null; | |
| let thrustNode = null; // persistent low-noise loop while thrusting | |
| let muted = false; | |
| function ensureAudio() { | |
| if (audioCtx) return; | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| if (!AC) return; | |
| audioCtx = new AC(); | |
| masterGain = audioCtx.createGain(); | |
| masterGain.gain.value = muted ? 0 : 0.6; | |
| masterGain.connect(audioCtx.destination); | |
| } | |
| function startThrustSound() { | |
| if (!audioCtx || thrustNode) return; | |
| // Build a noise buffer once | |
| const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 1.0, audioCtx.sampleRate); | |
| const ch = buf.getChannelData(0); | |
| for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * 0.8; | |
| const noise = audioCtx.createBufferSource(); | |
| noise.buffer = buf; | |
| noise.loop = true; | |
| // Bandpass for that rocket rumble | |
| const bp = audioCtx.createBiquadFilter(); | |
| bp.type = 'bandpass'; | |
| bp.frequency.value = 180; | |
| bp.Q.value = 0.7; | |
| // A sub sine to give body | |
| const sub = audioCtx.createOscillator(); | |
| sub.type = 'sine'; | |
| sub.frequency.value = 70; | |
| const g = audioCtx.createGain(); | |
| g.gain.value = 0.0; | |
| g.gain.setTargetAtTime(0.35, audioCtx.currentTime, 0.05); | |
| const subGain = audioCtx.createGain(); | |
| subGain.gain.value = 0.25; | |
| noise.connect(bp).connect(g); | |
| sub.connect(subGain).connect(g); | |
| g.connect(masterGain); | |
| noise.start(); | |
| sub.start(); | |
| thrustNode = { noise, sub, gain: g, subGain, bp }; | |
| } | |
| function stopThrustSound() { | |
| if (!audioCtx || !thrustNode) return; | |
| const { noise, sub, gain, subGain } = thrustNode; | |
| const t = audioCtx.currentTime; | |
| gain.gain.cancelScheduledValues(t); | |
| gain.gain.setTargetAtTime(0, t, 0.08); | |
| setTimeout(() => { | |
| try { noise.stop(); sub.stop(); } catch(e) {} | |
| try { gain.disconnect(); subGain.disconnect(); } catch(e) {} | |
| }, 250); | |
| thrustNode = null; | |
| } | |
| function blip(freq, dur, type = 'square', vol = 0.15, slide = 0) { | |
| if (!audioCtx) return; | |
| const t = audioCtx.currentTime; | |
| const o = audioCtx.createOscillator(); | |
| const g = audioCtx.createGain(); | |
| o.type = type; | |
| o.frequency.setValueAtTime(freq, t); | |
| if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur); | |
| g.gain.setValueAtTime(0, t); | |
| g.gain.linearRampToValueAtTime(vol, t + 0.005); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t + dur); | |
| o.connect(g).connect(masterGain); | |
| o.start(t); | |
| o.stop(t + dur + 0.02); | |
| } | |
| function playRcs() { | |
| if (!audioCtx) return; | |
| blip(520, 0.06, 'square', 0.06, -120); | |
| } | |
| function playCrash() { | |
| if (!audioCtx) return; | |
| // Big noise burst | |
| const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.6, audioCtx.sampleRate); | |
| const ch = buf.getChannelData(0); | |
| for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length); | |
| const n = audioCtx.createBufferSource(); | |
| n.buffer = buf; | |
| const bp = audioCtx.createBiquadFilter(); | |
| bp.type = 'lowpass'; | |
| bp.frequency.setValueAtTime(1500, audioCtx.currentTime); | |
| bp.frequency.exponentialRampToValueAtTime(120, audioCtx.currentTime + 0.6); | |
| const g = audioCtx.createGain(); | |
| g.gain.value = 0.6; | |
| n.connect(bp).connect(g).connect(masterGain); | |
| n.start(); | |
| // Sub thump | |
| blip(90, 0.4, 'sine', 0.5, -40); | |
| // Sparkle crunches | |
| setTimeout(() => blip(220, 0.08, 'sawtooth', 0.15, -80), 80); | |
| setTimeout(() => blip(160, 0.12, 'sawtooth', 0.12, -60), 180); | |
| } | |
| function playWin() { | |
| if (!audioCtx) return; | |
| // Triumphant arpeggio | |
| const notes = [523.25, 659.25, 783.99, 1046.5]; // C5 E5 G5 C6 | |
| notes.forEach((f, i) => { | |
| setTimeout(() => blip(f, 0.22, 'triangle', 0.18), i * 110); | |
| }); | |
| // Soft pad swell | |
| setTimeout(() => { | |
| const t = audioCtx.currentTime; | |
| const o = audioCtx.createOscillator(); | |
| const g = audioCtx.createGain(); | |
| o.type = 'sine'; | |
| o.frequency.value = 261.63; | |
| g.gain.setValueAtTime(0, t); | |
| g.gain.linearRampToValueAtTime(0.08, t + 0.2); | |
| g.gain.exponentialRampToValueAtTime(0.0001, t + 1.4); | |
| o.connect(g).connect(masterGain); | |
| o.start(t); o.stop(t + 1.5); | |
| }, 350); | |
| } | |
| function playClick() { | |
| if (!audioCtx) return; | |
| blip(900, 0.04, 'square', 0.08); | |
| } | |
| function setMuted(m) { | |
| muted = m; | |
| if (masterGain) masterGain.gain.value = muted ? 0 : 0.6; | |
| if (muted) stopThrustSound(); | |
| } | |
| // ---- Input ------------------------------------------------------------- | |
| const keys = {}; | |
| window.addEventListener('keydown', (e) => { | |
| ensureAudio(); | |
| if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); | |
| keys[e.key] = true; | |
| if (e.key === 'r' || e.key === 'R') { playClick(); reset(); } | |
| if (e.key === 'm' || e.key === 'M') { setMuted(!muted); } | |
| if (['ArrowUp','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault(); | |
| }, { passive: false }); | |
| window.addEventListener('keyup', (e) => { keys[e.key] = false; }); | |
| // ---- Helpers ----------------------------------------------------------- | |
| function rotate(px, py, a) { | |
| const c = Math.cos(a), s = Math.sin(a); | |
| return { x: px * c - py * s, y: px * s + py * c }; | |
| } | |
| function landerCorners() { | |
| // rectangle local coords, centered at (0,0); up is -y | |
| const w = lander.w, h = lander.h; | |
| const pts = [ | |
| { x: -w/2, y: -h/2 }, | |
| { x: w/2, y: -h/2 }, | |
| { x: w/2, y: h/2 }, | |
| { x: -w/2, y: h/2 }, | |
| ]; | |
| return pts.map(p => { | |
| const r = rotate(p.x, p.y, lander.angle); | |
| return { x: lander.x + r.x, y: lander.y + r.y }; | |
| }); | |
| } | |
| function segmentIntersect(p1, p2, p3, p4) { | |
| const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x); | |
| if (Math.abs(d) < 1e-9) return null; | |
| const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d; | |
| const u = -((p1.x - p2.x) * (p1.y - p3.y) - (p1.y - p2.y) * (p1.x - p3.x)) / d; | |
| if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { | |
| return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }; | |
| } | |
| return null; | |
| } | |
| function terrainYAt(x) { | |
| // linear interpolation along terrain top edge | |
| for (let i = 0; i < terrain.length - 1; i++) { | |
| const a = terrain[i], b = terrain[i+1]; | |
| if (x >= a.x && x <= b.x) { | |
| const t = (x - a.x) / Math.max(1, (b.x - a.x)); | |
| return { y: a.y + (b.y - a.y) * t, seg: i, a, b }; | |
| } | |
| } | |
| return null; | |
| } | |
| function isOnPad(x) { | |
| return x >= pad.x1 && x <= pad.x2; | |
| } | |
| // ---- Physics ----------------------------------------------------------- | |
| function update() { | |
| if (status !== 'flying') return; | |
| const thrusting = keys['ArrowUp'] && lander.fuel > 0; | |
| const turningL = keys['ArrowLeft']; | |
| const turningR = keys['ArrowRight']; | |
| // rotation | |
| if (turningL) { lander.angle -= ROT_SPEED; playRcs(); } | |
| if (turningR) { lander.angle += ROT_SPEED; playRcs(); } | |
| // small visual wobble when out of fuel | |
| if (lander.fuel <= 0 && (turningL || turningR)) { | |
| lander.angle += (Math.random() - 0.5) * 0.01; | |
| } | |
| // thrust sound start/stop | |
| if (thrusting) startThrustSound(); | |
| else stopThrustSound(); | |
| // gravity | |
| lander.vy += GRAVITY; | |
| // thrust along facing | |
| if (thrusting) { | |
| // up along lander: facing dir is -y in local; in world, thrust vector points opposite of "up local"? | |
| // Our lander is drawn pointing up (angle 0 -> -y). Thrust pushes along facing. | |
| // Use the up vector: (sin a, -cos a) -> that gives the direction the nose points. | |
| // We want thrust to push the lander in the direction the nose points? No — engines fire downward from the lander, so thrust vector points UP from the lander bottom, which is along the nose direction (opposite of falling). | |
| // Convention here: angle 0, nose up -> thrust vector is (0, -1) (up on screen). | |
| // After rotation by angle a, the local up (0,-1) becomes (sin a, -cos a). | |
| const ax = Math.sin(lander.angle); | |
| const ay = -Math.cos(lander.angle); | |
| lander.vx += ax * MAIN_THRUST; | |
| lander.vy += ay * MAIN_THRUST; | |
| lander.fuel = Math.max(0, lander.fuel - FUEL_BURN_MAIN); | |
| // exhaust particles | |
| const exhaustDir = -1; // opposite of thrust | |
| const ex = lander.x - ax * (lander.h * 0.4); | |
| const ey = lander.y - ay * (lander.h * 0.4); | |
| for (let i = 0; i < 3; i++) { | |
| const spread = (Math.random() - 0.5) * 0.6; | |
| const speed = 1.5 + Math.random() * 1.5; | |
| particles.push({ | |
| x: ex, y: ey, | |
| vx: -ax * speed + Math.cos(lander.angle + Math.PI/2) * spread * speed, | |
| vy: -ay * speed + Math.sin(lander.angle + Math.PI/2) * spread * speed, | |
| life: 30 + Math.random() * 15, | |
| max: 45, | |
| hot: true, | |
| }); | |
| } | |
| lander.flameScale = 1.0; | |
| } else { | |
| lander.flameScale *= 0.85; | |
| } | |
| if (turningL || turningR) { | |
| lander.fuel = Math.max(0, lander.fuel - FUEL_BURN_RCS); | |
| } | |
| // tiny drag for floaty feel | |
| lander.vx *= 0.999; | |
| // integrate | |
| lander.x += lander.vx; | |
| lander.y += lander.vy; | |
| // walls | |
| if (lander.x < 8) { lander.x = 8; lander.vx = Math.abs(lander.vx) * 0.4; } | |
| if (lander.x > W-8){ lander.x = W-8;lander.vx = -Math.abs(lander.vx) * 0.4; } | |
| if (lander.y < 8) { lander.y = 8; lander.vy = Math.abs(lander.vy) * 0.4; } | |
| // collision against terrain top edge (only the visible top; terrain includes the | |
| // polygon down to bottom corners but segIntersect below handles that fine) | |
| const corners = landerCorners(); | |
| for (let i = 0; i < terrain.length - 1; i++) { | |
| const a = terrain[i], b = terrain[i+1]; | |
| // skip the "down to bottom" segments at the ends so we don't trigger on the canvas floor | |
| if (a.y >= H - 1 || b.y >= H - 1) continue; | |
| for (let j = 0; j < 4; j++) { | |
| const c1 = corners[j]; | |
| const c2 = corners[(j+1) % 4]; | |
| const hit = segmentIntersect(c1, c2, a, b); | |
| if (hit) { | |
| onCollision(i, hit); | |
| return; | |
| } | |
| } | |
| } | |
| // particles | |
| for (const p of particles) { | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.vy += 0.02; // small gravity for exhaust smoke | |
| p.life -= 1; | |
| } | |
| particles = particles.filter(p => p.life > 0); | |
| } | |
| function onCollision(segIndex, hitPoint) { | |
| const onPadX = hitPoint.x >= pad.x1 && hitPoint.x <= pad.x2; | |
| const tiltOk = Math.abs(lander.angle) < SAFE_TILT || | |
| Math.abs(Math.abs(lander.angle) - Math.PI) < SAFE_TILT; | |
| const vyOk = Math.abs(lander.vy) < SAFE_VY; | |
| const vxOk = Math.abs(lander.vx) < SAFE_VX; | |
| if (onPadX && tiltOk && vyOk && vxOk) { | |
| status = 'win'; | |
| message = 'TOUCHDOWN!'; | |
| messageColor = '#6ee7a0'; | |
| stopThrustSound(); | |
| playWin(); | |
| } else { | |
| status = 'crash'; | |
| // friendly reason | |
| let reason = 'CRASHED'; | |
| if (!onPadX) reason = 'OFF PAD'; | |
| else if (!tiltOk) reason = 'BAD ANGLE'; | |
| else if (!vyOk) reason = 'TOO FAST (V)'; | |
| else if (!vxOk) reason = 'TOO FAST (H)'; | |
| message = reason; | |
| messageColor = '#ff6b6b'; | |
| stopThrustSound(); | |
| playCrash(); | |
| // explosion | |
| for (let i = 0; i < 60; i++) { | |
| const a = Math.random() * Math.PI * 2; | |
| const s = 1 + Math.random() * 3.5; | |
| particles.push({ | |
| x: hitPoint.x, y: hitPoint.y, | |
| vx: Math.cos(a) * s, | |
| vy: Math.sin(a) * s, | |
| life: 40 + Math.random() * 30, | |
| max: 70, | |
| hot: true, | |
| }); | |
| } | |
| } | |
| } | |
| // ---- Drawing ----------------------------------------------------------- | |
| function drawStars() { | |
| for (const s of stars) { | |
| s.tw += 0.04; | |
| const a = 0.5 + 0.5 * Math.sin(s.tw); | |
| ctx.fillStyle = `rgba(220, 230, 255, ${0.25 + 0.55 * a})`; | |
| ctx.beginPath(); | |
| ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawTerrain() { | |
| // pad base glow | |
| ctx.fillStyle = 'rgba(110, 231, 160, 0.18)'; | |
| ctx.fillRect(pad.x1 - 4, pad.y - 4, pad.w + 8, 4); | |
| // terrain fill | |
| ctx.beginPath(); | |
| ctx.moveTo(terrain[0].x, terrain[0].y); | |
| for (let i = 1; i < terrain.length; i++) ctx.lineTo(terrain[i].x, terrain[i].y); | |
| ctx.closePath(); | |
| ctx.fillStyle = '#101521'; | |
| ctx.fill(); | |
| // terrain outline | |
| ctx.beginPath(); | |
| ctx.moveTo(terrain[0].x, terrain[0].y); | |
| for (let i = 1; i < terrain.length; i++) ctx.lineTo(terrain[i].x, terrain[i].y); | |
| ctx.strokeStyle = '#3a4566'; | |
| ctx.lineWidth = 1.4; | |
| ctx.stroke(); | |
| // pad marker | |
| ctx.strokeStyle = '#6ee7a0'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(pad.x1, pad.y); | |
| ctx.lineTo(pad.x2, pad.y); | |
| ctx.stroke(); | |
| // pad flags | |
| function flag(x) { | |
| ctx.strokeStyle = '#6ee7a0'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, pad.y); | |
| ctx.lineTo(x, pad.y - 14); | |
| ctx.stroke(); | |
| ctx.fillStyle = '#6ee7a0'; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, pad.y - 14); | |
| ctx.lineTo(x + 8, pad.y - 11); | |
| ctx.lineTo(x, pad.y - 8); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| flag(pad.x1); | |
| flag(pad.x2); | |
| } | |
| function drawLander() { | |
| ctx.save(); | |
| ctx.translate(lander.x, lander.y); | |
| ctx.rotate(lander.angle); | |
| // body | |
| ctx.fillStyle = '#dbe2ee'; | |
| ctx.strokeStyle = '#6a7793'; | |
| ctx.lineWidth = 1.2; | |
| ctx.beginPath(); | |
| ctx.moveTo(0, -lander.h/2); | |
| ctx.lineTo(lander.w/2, -lander.h/4); | |
| ctx.lineTo(lander.w/2, lander.h/2); | |
| ctx.lineTo(-lander.w/2, lander.h/2); | |
| ctx.lineTo(-lander.w/2, -lander.h/4); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| // window | |
| ctx.fillStyle = '#6ee7a0'; | |
| ctx.beginPath(); | |
| ctx.arc(0, -lander.h/6, 3, 0, Math.PI * 2); | |
| ctx.fill(); | |
| // legs | |
| ctx.strokeStyle = '#9aa6bd'; | |
| ctx.lineWidth = 1.6; | |
| ctx.beginPath(); | |
| ctx.moveTo(-lander.w/2 + 2, lander.h/2); | |
| ctx.lineTo(-lander.w/2 - 6, lander.h/2 + 8); | |
| ctx.moveTo(lander.w/2 - 2, lander.h/2); | |
| ctx.lineTo(lander.w/2 + 6, lander.h/2 + 8); | |
| ctx.stroke(); | |
| // flame | |
| if (lander.flameScale > 0.05 && lander.fuel > 0) { | |
| const f = lander.flameScale; | |
| const grad = ctx.createLinearGradient(0, lander.h/2, 0, lander.h/2 + 30 * f); | |
| grad.addColorStop(0, 'rgba(255, 220, 120, 0.95)'); | |
| grad.addColorStop(0.5, 'rgba(255, 120, 60, 0.6)'); | |
| grad.addColorStop(1, 'rgba(120, 60, 30, 0)'); | |
| ctx.fillStyle = grad; | |
| ctx.beginPath(); | |
| ctx.moveTo(-6, lander.h/2); | |
| ctx.quadraticCurveTo(0, lander.h/2 + 26 * f, 6, lander.h/2); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawParticles() { | |
| for (const p of particles) { | |
| const a = Math.max(0, p.life / p.max); | |
| const r = p.hot ? 255 : 180; | |
| const g = p.hot ? 200 : 180; | |
| const b = p.hot ? 90 : 200; | |
| ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, 1.4 + a * 1.2, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawThrustVector() { | |
| // small arrow showing thrust direction (only when thrusting) | |
| if (!(keys['ArrowUp'] && lander.fuel > 0 && status === 'flying')) return; | |
| const ax = Math.sin(lander.angle); | |
| const ay = -Math.cos(lander.angle); | |
| const sx = lander.x + ax * 36; | |
| const sy = lander.y + ay * 36; | |
| ctx.strokeStyle = 'rgba(110, 231, 160, 0.85)'; | |
| ctx.lineWidth = 1.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(lander.x, lander.y); | |
| ctx.lineTo(sx, sy); | |
| ctx.stroke(); | |
| // arrow head | |
| const head = 6; | |
| const ang = Math.atan2(ay, ax); | |
| ctx.beginPath(); | |
| ctx.moveTo(sx, sy); | |
| ctx.lineTo(sx - head * Math.cos(ang - 0.4), sy - head * Math.sin(ang - 0.4)); | |
| ctx.lineTo(sx - head * Math.cos(ang + 0.4), sy - head * Math.sin(ang + 0.4)); | |
| ctx.closePath(); | |
| ctx.fillStyle = 'rgba(110, 231, 160, 0.85)'; | |
| ctx.fill(); | |
| } | |
| function drawHUDOverlay() { | |
| // fuel bar at top-right | |
| const barW = 140, barH = 8; | |
| const x = W - barW - 16, y = 16; | |
| ctx.fillStyle = 'rgba(255,255,255,0.06)'; | |
| ctx.fillRect(x, y, barW, barH); | |
| const f = lander.fuel / START_FUEL; | |
| const col = f > 0.4 ? '#6ee7a0' : (f > 0.15 ? '#ffcc66' : '#ff6b6b'); | |
| ctx.fillStyle = col; | |
| ctx.fillRect(x, y, barW * f, barH); | |
| ctx.strokeStyle = 'rgba(255,255,255,0.25)'; | |
| ctx.strokeRect(x, y, barW, barH); | |
| ctx.fillStyle = '#9fb0c5'; | |
| ctx.font = '10px Menlo, monospace'; | |
| ctx.textAlign = 'right'; | |
| ctx.fillText('FUEL', x - 6, y + 8); | |
| // velocity vector mini-HUD near lander | |
| if (status === 'flying') { | |
| const speed = Math.hypot(lander.vx, lander.vy); | |
| const col = speed < 2 ? '#6ee7a0' : (speed < 4 ? '#ffcc66' : '#ff6b6b'); | |
| ctx.fillStyle = col; | |
| ctx.beginPath(); | |
| ctx.arc(lander.x, lander.y - 30, 2.5, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawMessage() { | |
| if (!message) return; | |
| ctx.fillStyle = 'rgba(5, 6, 10, 0.65)'; | |
| ctx.fillRect(0, H/2 - 40, W, 80); | |
| ctx.fillStyle = messageColor; | |
| ctx.font = 'bold 28px Menlo, monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(message, W/2, H/2); | |
| ctx.fillStyle = '#9fb0c5'; | |
| ctx.font = '12px Menlo, monospace'; | |
| ctx.fillText('Press R to restart', W/2, H/2 + 26); | |
| } | |
| function updateHUD() { | |
| const alt = Math.max(0, Math.round(terrainYAt(lander.x)?.y - lander.y || 0)); | |
| const altEl = document.getElementById('hud-alt'); | |
| altEl.textContent = String(alt).padStart(3, '0'); | |
| altEl.className = alt > 80 ? 'ok' : (alt > 30 ? 'warn' : 'bad'); | |
| document.getElementById('hud-vx').textContent = lander.vx.toFixed(1); | |
| document.getElementById('hud-vy').textContent = lander.vy.toFixed(1); | |
| const fuelEl = document.getElementById('hud-fuel'); | |
| fuelEl.textContent = Math.round(lander.fuel); | |
| fuelEl.className = lander.fuel > 40 ? 'ok' : (lander.fuel > 15 ? 'warn' : 'bad'); | |
| let deg = Math.round(lander.angle * 180 / Math.PI); | |
| // normalize to -180..180 | |
| deg = ((deg + 180) % 360 + 360) % 360 - 180; | |
| document.getElementById('hud-rot').textContent = `${deg}\u00B0`; | |
| const stEl = document.getElementById('hud-status'); | |
| stEl.textContent = status.toUpperCase(); | |
| stEl.className = status === 'flying' ? 'ok' : (status === 'win' ? 'ok' : 'bad'); | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, W, H); | |
| drawStars(); | |
| drawTerrain(); | |
| drawParticles(); | |
| drawThrustVector(); | |
| drawLander(); | |
| drawHUDOverlay(); | |
| drawMessage(); | |
| } | |
| function loop() { | |
| update(); | |
| draw(); | |
| updateHUD(); | |
| requestAnimationFrame(loop); | |
| } | |
| reset(); | |
| loop(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Build a basic Lunar Lander game as a single HTML file (HTML + CSS + JavaScript on a <canvas>). | |
| Mechanics: | |
| A small lander starts near the top of the screen and is pulled downward by constant gravity. | |
| The player presses the up arrow to fire the main thruster (counteracts gravity) and left/right arrows to rotate the lander, applying thrust in the direction it's pointing. | |
| Fuel is limited and decreases while thrusting; show a fuel gauge. When fuel runs out, thrust no longer works. | |
| Generate a jagged terrain along the bottom with one flat landing pad marked in a different color. | |
| Win if the lander touches the pad while upright (small tilt tolerance) and below a safe descent speed. Crash otherwise (too fast, too tilted, or hitting non-pad terrain). | |
| Display live readouts: altitude, horizontal speed, vertical speed, fuel, and rotation. | |
| Show a win/lose message and a "Press R to restart" option. | |
| Keep it self-contained with no external libraries, use simple vector-style graphics, and make the physics feel slightly floaty to match low lunar gravity. | |
| Add Sounds |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment