diff --git a/js/game.js b/js/game.js index bdd9ba4..c6a5221 100644 --- a/js/game.js +++ b/js/game.js @@ -14,6 +14,46 @@ const gravity = 1900; const groundY = 420; const worldWidth = 6000; +let audioCtx = null; + +function ensureAudioCtx() { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); +} + +function playSfx(type) { + try { + ensureAudioCtx(); + const now = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + + const presets = { + jump: { f0: 420, f1: 680, dur: 0.1, wave: 'triangle', vol: 0.06 }, + squish: { f0: 180, f1: 120, dur: 0.12, wave: 'square', vol: 0.07 }, + duck: { f0: 240, f1: 180, dur: 0.06, wave: 'sine', 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_land: { f0: 140, f1: 70, dur: 0.18, wave: 'square', vol: 0.08 }, + hit: { f0: 260, f1: 140, dur: 0.1, wave: 'square', vol: 0.06 } + }; + + const p = presets[type]; + if (!p) return; + osc.type = p.wave; + osc.frequency.setValueAtTime(p.f0, now); + osc.frequency.exponentialRampToValueAtTime(Math.max(40, p.f1), now + p.dur); + gain.gain.setValueAtTime(p.vol, now); + gain.gain.exponentialRampToValueAtTime(0.0001, now + p.dur); + osc.start(now); + osc.stop(now + p.dur); + } catch (_) { + // ignore audio errors in unsupported environments + } +} + const waterGaps = [ { x: 700, width: 130 }, { x: 1300, width: 150 }, @@ -43,6 +83,7 @@ function createInitialState() { pteroSpawnTimer: 0, meteors: [], meteorSpawnTimer: 0, + floatingTexts: [], player: { x: 160, y: groundY - 76, @@ -79,6 +120,7 @@ window.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' && state.player.onGround && state.running) { state.player.vy = -state.player.jumpPower; state.player.onGround = false; + playSfx('jump'); } }); window.addEventListener('keyup', (e) => keys.delete(e.key)); @@ -175,10 +217,37 @@ function drawMeteors() { } } +function addFloatingText(x, y, text, color = '#ffe066') { + state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 }); +} + +function updateFloatingTexts(dt) { + for (const t of state.floatingTexts) { + t.ttl -= dt; + t.y += t.vy * dt; + } + state.floatingTexts = state.floatingTexts.filter((t) => t.ttl > 0); +} + +function drawFloatingTexts() { + ctx.save(); + ctx.font = 'bold 22px Arial'; + ctx.textAlign = 'center'; + for (const t of state.floatingTexts) { + const alpha = Math.max(0, Math.min(1, t.ttl / 0.8)); + ctx.globalAlpha = alpha; + ctx.fillStyle = t.color; + ctx.fillText(t.text, t.x - state.cameraX, t.y); + } + ctx.restore(); +} + const intersects = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; function updateHeartsUI() { - heartsEl.textContent = '❤'.repeat(state.hearts) + '🖤'.repeat(Math.max(0, 3 - state.hearts)); + const active = ''.repeat(state.hearts); + const lost = ''.repeat(Math.max(0, 3 - state.hearts)); + heartsEl.innerHTML = active + lost; } async function loadTopScores() { @@ -215,6 +284,7 @@ function applyHit(reason) { if (state.hitCooldown > 0 || state.dead) return; state.hearts -= 1; state.hitCooldown = 1; + playSfx('hit'); if (state.hearts <= 0) kill(reason); } @@ -239,6 +309,9 @@ function updateAnts(dt) { if (stomped) { ant.alive = false; p.vy = -380; + state.score += 50; + addFloatingText(ant.x + ant.width / 2, ant.y - 10, '+50', '#ffe066'); + playSfx('squish'); } else { applyHit('A swarm of ants got you.'); } @@ -286,6 +359,7 @@ function updatePterodactyls(dt) { height: lane.height, vx: -280 }); + playSfx('ptero_spawn'); state.pteroSpawnTimer = 2.2; } @@ -304,13 +378,20 @@ function updateMeteors(dt) { if (state.meteorSpawnTimer <= 0) { const spawnX = state.cameraX + 80 + Math.random() * (canvas.width - 160); - state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420 }); + state.meteors.push({ x: spawnX, y: -30, width: 24, height: 30, vy: 420, landed: false }); + playSfx('meteor_spawn'); state.meteorSpawnTimer = 1.5; } const p = state.player; for (const m of state.meteors) { m.y += m.vy * dt; + + if (!m.landed && m.y + m.height >= groundY) { + m.landed = true; + playSfx('meteor_land'); + } + if (intersects(p, m)) { applyHit('Meteor strike!'); m.y = canvas.height + 100; @@ -325,7 +406,9 @@ function updatePlayer(dt) { if (keys.has('ArrowLeft')) move -= 1; if (keys.has('ArrowRight')) move += 1; + const wasDucking = p.ducking; p.ducking = keys.has('ArrowDown') && p.onGround; + if (!wasDucking && p.ducking) playSfx('duck'); const targetHeight = p.ducking ? p.duckHeight : p.standingHeight; if (targetHeight !== p.height) { p.y += p.height - targetHeight; @@ -370,6 +453,7 @@ function tick(ts) { updateAnts(dt); updatePterodactyls(dt); updateMeteors(dt); + updateFloatingTexts(dt); } ctx.clearRect(0, 0, canvas.width, canvas.height); @@ -377,6 +461,7 @@ function tick(ts) { drawAnts(); drawPterodactyls(); drawMeteors(); + drawFloatingTexts(); drawTRex(state.player); scoreEl.textContent = `Score: ${state.score}`; diff --git a/styles.css b/styles.css index 527c0d9..18e9cec 100644 --- a/styles.css +++ b/styles.css @@ -46,3 +46,10 @@ button, input { margin-top: 8px; } ol { padding-left: 22px; } + +.heart { + display: inline-block; + margin-right: 2px; +} +.heart-active { color: #e02424; } +.heart-lost { color: #111; }