// Firefly Jar: catch the fireflies while they glow. // A Twiddle Play tutorial game. Plain JavaScript, no libraries. // ---------- The canvas ---------- const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); let W = 0; // canvas width in CSS pixels let H = 0; // canvas height in CSS pixels function resize() { // Draw at the screen's real pixel density (capped at 2 for speed), so nothing looks blurry. const dpr = Math.min(window.devicePixelRatio || 1, 2); W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // from now on, 1 unit = 1 CSS pixel makeStars(); } // ---------- The night sky ---------- let stars = []; function makeStars() { stars = []; for (let i = 0; i < 60; i++) { stars.push({ x: Math.random() * W, y: Math.random() * H * 0.7, r: 0.4 + Math.random() * 1.2 }); } } function drawBackground() { const sky = ctx.createLinearGradient(0, 0, 0, H); sky.addColorStop(0, '#0b1030'); sky.addColorStop(1, '#23306a'); ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H); ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; for (const s of stars) { ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill(); } // dark hills along the bottom ctx.fillStyle = '#081420'; ctx.beginPath(); ctx.moveTo(0, H); ctx.lineTo(0, H - 80); ctx.quadraticCurveTo(W * 0.3, H - 130, W * 0.55, H - 85); ctx.quadraticCurveTo(W * 0.8, H - 45, W, H - 100); ctx.lineTo(W, H); ctx.closePath(); ctx.fill(); } // ---------- Fireflies ---------- const TOP = 90; // fireflies stay below the score line... const BOTTOM = 150; // ...and above the jar at the bottom let fireflies = []; let speed = 50; // pixels per second let blinkSpeed = 1.7; // how fast the glow cycles function makeFirefly() { return { x: 30 + Math.random() * (W - 60), y: TOP + Math.random() * Math.max(1, H - TOP - BOTTOM), angle: Math.random() * Math.PI * 2, // the direction it flies blink: Math.random() * Math.PI * 2, // where it is in its glow cycle }; } function isLit(f) { return Math.sin(f.blink) > 0.3; } function moveFirefly(f, dt) { f.angle += (Math.random() - 0.5) * 4 * dt; // wander a little f.x += Math.cos(f.angle) * speed * dt; f.y += Math.sin(f.angle) * speed * dt; f.blink += blinkSpeed * dt; // turn back at the edges of the play area if (f.x < 20 || f.x > W - 20) f.angle = Math.PI - f.angle; if (f.y < TOP || f.y > H - BOTTOM) f.angle = -f.angle; f.x = Math.max(20, Math.min(W - 20, f.x)); f.y = Math.max(TOP, Math.min(H - BOTTOM, f.y)); } function drawFirefly(f) { const glow = Math.max(0, Math.sin(f.blink)); // 0 = dark, 1 = brightest ctx.globalCompositeOperation = 'lighter'; // add light instead of paint ctx.globalAlpha = glow * 0.35; ctx.fillStyle = '#ffdc6e'; ctx.beginPath(); ctx.arc(f.x, f.y, 10 + glow * 14, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = isLit(f) ? '#fff3b0' : '#56607e'; ctx.beginPath(); ctx.arc(f.x, f.y, 5, 0, Math.PI * 2); ctx.fill(); } // ---------- Sparkles ---------- let sparks = []; function burst(x, y) { for (let i = 0; i < 12; i++) { const a = Math.random() * Math.PI * 2; const v = 60 + Math.random() * 120; sparks.push({ x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.5 }); } } function updateSparks(dt) { for (const s of sparks) { s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt; } sparks = sparks.filter(function (s) { return s.life > 0; }); } function drawSparks() { ctx.fillStyle = '#ffe27a'; for (const s of sparks) { ctx.globalAlpha = s.life / 0.5; ctx.fillRect(s.x - 2, s.y - 2, 4, 4); } ctx.globalAlpha = 1; } // ---------- Catching ---------- let score = 0; // Pointer events cover touch, mouse and pen with one listener. canvas.addEventListener('pointerdown', function (e) { const rect = canvas.getBoundingClientRect(); tryCatch(e.clientX - rect.left, e.clientY - rect.top); }); function tryCatch(x, y) { // Find the nearest firefly within a finger's reach (44 px). let nearest = null; let nearestDist = 44; for (const f of fireflies) { const d = Math.hypot(f.x - x, f.y - y); if (d < nearestDist) { nearest = f; nearestDist = d; } } if (!nearest || !isLit(nearest)) return; // empty sky or a sleeping firefly fireflies.splice(fireflies.indexOf(nearest), 1); // into the jar it goes... fireflies.push(makeFirefly()); // ...and a new one flies in score += 10; burst(nearest.x, nearest.y); } // ---------- The game loop ---------- let lastTime = 0; function update(dt) { for (const f of fireflies) moveFirefly(f, dt); updateSparks(dt); } function draw() { drawBackground(); for (const f of fireflies) drawFirefly(f); drawSparks(); ctx.fillStyle = '#ffffff'; ctx.font = '700 22px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(String(score), W / 2, 34); } function frame(time) { // dt = seconds since the last frame, capped so one slow frame can't make things jump const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0; lastTime = time; update(dt); draw(); requestAnimationFrame(frame); } // ---------- Start ---------- resize(); window.addEventListener('resize', resize); for (let i = 0; i < 8; i++) fireflies.push(makeFirefly()); requestAnimationFrame(frame);