← back to Flappy Flight
index.html
646 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0e1420">
<meta name="description" content="Flappy Flight — a polished, self-contained Flappy-Bird-style game. No assets, no libraries.">
<title>Flappy Flight</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: #0e1420;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
-webkit-user-select: none; user-select: none;
touch-action: manipulation;
}
#wrap {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
}
canvas {
display: block;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.06);
cursor: pointer;
}
</style>
</head>
<body>
<div id="wrap"><canvas id="game" aria-label="Flappy Flight game canvas" role="img"></canvas></div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// ---------- sizing ----------
const GW = 420, GH = 640; // logical game size
let scale = 1;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const availW = window.innerWidth, availH = window.innerHeight;
scale = Math.min(availW / GW, availH / GH) * 0.98;
canvas.style.width = (GW * scale) + 'px';
canvas.style.height = (GH * scale) + 'px';
canvas.width = Math.round(GW * scale * dpr);
canvas.height = Math.round(GH * scale * dpr);
ctx.setTransform(canvas.width / GW, 0, 0, canvas.height / GH, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// ---------- game clock ----------
// A single monotonic clock that ONLY advances while the game is actively
// running (never during a pause). All time-based visuals read from this so a
// pause genuinely freezes the whole scene instead of letting the sky, clouds,
// and parallax hills keep drifting.
let clock = 0;
// ---------- audio (generated, no assets) ----------
let actx = null;
let muted = false;
try { muted = localStorage.getItem('ff_muted') === '1'; } catch (e) {}
function audio() {
if (muted) return null;
if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){} }
if (actx && actx.state === 'suspended') actx.resume();
return actx;
}
function beep(freq, dur, type, vol, slide) {
const a = audio(); if (!a) return;
const o = a.createOscillator(), g = a.createGain();
o.type = type || 'sine';
o.frequency.setValueAtTime(freq, a.currentTime);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(slide, 1), a.currentTime + dur);
g.gain.setValueAtTime(vol || 0.08, a.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
o.connect(g); g.connect(a.destination);
o.start(); o.stop(a.currentTime + dur);
}
const sfx = {
flap: () => beep(430, 0.09, 'triangle', 0.06, 620),
score: () => { beep(880, 0.09, 'sine', 0.07); setTimeout(() => beep(1320, 0.12, 'sine', 0.07), 70); },
hit: () => beep(200, 0.25, 'sawtooth', 0.10, 60),
swoosh:() => beep(320, 0.18, 'sine', 0.04, 90)
};
function toggleMute() {
muted = !muted;
try { localStorage.setItem('ff_muted', muted ? '1' : '0'); } catch (e) {}
if (!muted) sfx.swoosh();
}
// ---------- constants ----------
const GROUND_H = 88;
const SKY_H = GH - GROUND_H;
const GRAVITY = 1350; // px/s^2
const FLAP_V = -410; // px/s
const MAX_FALL = 620;
const BIRD_X = GW * 0.30;
const BIRD_R = 14;
const PIPE_W = 66;
// speaker icon hit-box (top-right), in logical coords
const SPK = { x: GW - 40, y: 26, w: 30, h: 26 };
// difficulty ramps with score
function diff(score) {
const t = Math.min(score / 30, 1);
return {
speed: 145 + 95 * t, // scroll speed px/s
gap: Math.max(196 - score * 2.2, 128), // gap height
spacing: Math.max(258 - score * 1.6, 205) // px between pipes
};
}
// ---------- state ----------
const STATE = { READY: 0, PLAY: 1, DYING: 2, OVER: 3 };
let state = STATE.READY;
let paused = false;
let bird, pipes, score, best, distSincePipe, shake, flash, wingT, deadT, overT, groundX;
let clouds = [], stars = [];
best = 0;
try { best = +localStorage.getItem('ff_best') || 0; } catch(e) {}
function seedScenery() {
clouds = [];
for (let i = 0; i < 7; i++) clouds.push({
x: Math.random() * GW, y: 30 + Math.random() * 200,
s: 0.5 + Math.random() * 0.9, layer: Math.random() < 0.5 ? 0 : 1
});
stars = [];
for (let i = 0; i < 26; i++) stars.push({
x: Math.random() * GW, y: Math.random() * SKY_H * 0.7,
r: 0.6 + Math.random() * 1.3, tw: Math.random() * Math.PI * 2
});
}
seedScenery();
function reset() {
bird = { y: SKY_H * 0.45, v: 0, rot: 0 };
pipes = [];
score = 0;
distSincePipe = diff(0).spacing * 0.55;
shake = 0; flash = 0; wingT = 0; deadT = 0; overT = 0; groundX = 0;
paused = false;
}
reset();
function spawnPipe() {
const d = diff(score);
const margin = 56;
const gapY = margin + Math.random() * (SKY_H - d.gap - margin * 2);
pipes.push({ x: GW + PIPE_W, gapY, gap: d.gap, scored: false, wob: Math.random() * Math.PI * 2 });
}
// ---------- input ----------
function flap() {
if (paused) { paused = false; return; } // any tap un-pauses
if (state === STATE.READY) {
state = STATE.PLAY;
bird.v = FLAP_V;
sfx.flap();
return;
}
if (state === STATE.PLAY) {
bird.v = FLAP_V;
wingT = 0;
sfx.flap();
return;
}
if (state === STATE.OVER && overT > 0.45) {
reset();
state = STATE.READY;
sfx.swoosh();
}
}
function togglePause() {
if (state !== STATE.PLAY) return;
paused = !paused;
if (!paused) sfx.swoosh();
}
window.addEventListener('keydown', e => {
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
e.preventDefault();
if (!e.repeat) flap();
} else if (e.code === 'KeyP' || e.code === 'Escape') {
e.preventDefault();
if (!e.repeat) togglePause();
} else if (e.code === 'KeyM') {
e.preventDefault();
if (!e.repeat) toggleMute();
}
});
canvas.addEventListener('pointerdown', e => {
e.preventDefault();
// hit-test the speaker icon (convert client px -> logical coords)
const r = canvas.getBoundingClientRect();
const lx = (e.clientX - r.left) / r.width * GW;
const ly = (e.clientY - r.top) / r.height * GH;
if (lx >= SPK.x - 6 && lx <= SPK.x + SPK.w + 6 && ly >= SPK.y - 8 && ly <= SPK.y + SPK.h + 6) {
toggleMute();
return;
}
flap();
});
// auto-pause when the tab/window loses focus so you never come back mid-fall
window.addEventListener('blur', () => { if (state === STATE.PLAY) paused = true; });
document.addEventListener('visibilitychange', () => {
if (document.hidden && state === STATE.PLAY) paused = true;
});
// ---------- collision ----------
function circleRect(cx, cy, r, rx, ry, rw, rh) {
const nx = Math.max(rx, Math.min(cx, rx + rw));
const ny = Math.max(ry, Math.min(cy, ry + rh));
const dx = cx - nx, dy = cy - ny;
return dx * dx + dy * dy < r * r;
}
function die() {
state = STATE.DYING;
deadT = 0;
shake = 1;
flash = 1;
bird.v = Math.min(bird.v, -220);
sfx.hit();
if (score > best) {
best = score;
try { localStorage.setItem('ff_best', best); } catch(e) {}
}
}
// ---------- update ----------
function update(dt) {
const d = diff(score);
wingT += dt;
if (shake > 0) shake = Math.max(0, shake - dt * 2.6);
if (flash > 0) flash = Math.max(0, flash - dt * 3.2);
// parallax scenery always drifts a little
const sceneSpeed = (state === STATE.PLAY || state === STATE.READY) ? d.speed : 20;
for (const c of clouds) {
c.x -= sceneSpeed * (c.layer === 0 ? 0.18 : 0.32) * c.s * dt;
if (c.x < -120) { c.x = GW + 60 + Math.random() * 60; c.y = 30 + Math.random() * 200; }
}
if (state === STATE.READY) {
bird.y = SKY_H * 0.45 + Math.sin(clock * 2.63) * 9;
bird.rot = 0;
groundX = (groundX - d.speed * dt) % 24;
return;
}
if (state === STATE.PLAY) {
groundX = (groundX - d.speed * dt) % 24;
// bird physics
bird.v = Math.min(bird.v + GRAVITY * dt, MAX_FALL);
bird.y += bird.v * dt;
const target = bird.v < 0 ? -0.42 : Math.min(bird.v / MAX_FALL * 1.45, 1.35);
bird.rot += (target - bird.rot) * Math.min(1, dt * (bird.v < 0 ? 14 : 6));
// ceiling clamp
if (bird.y < BIRD_R) { bird.y = BIRD_R; bird.v = 0; }
// pipes
distSincePipe += d.speed * dt;
if (distSincePipe >= d.spacing) { distSincePipe = 0; spawnPipe(); }
for (const p of pipes) {
p.x -= d.speed * dt;
if (!p.scored && p.x + PIPE_W < BIRD_X - BIRD_R) {
p.scored = true;
score++;
sfx.score();
}
// collide
if (circleRect(BIRD_X, bird.y, BIRD_R - 1, p.x, -10, PIPE_W, p.gapY + 10) ||
circleRect(BIRD_X, bird.y, BIRD_R - 1, p.x, p.gapY + p.gap, PIPE_W, SKY_H - p.gapY - p.gap)) {
die();
}
}
pipes = pipes.filter(p => p.x > -PIPE_W - 10);
// ground
if (bird.y > SKY_H - BIRD_R) { bird.y = SKY_H - BIRD_R; die(); }
return;
}
if (state === STATE.DYING) {
deadT += dt;
bird.v = Math.min(bird.v + GRAVITY * 1.15 * dt, MAX_FALL * 1.2);
bird.y += bird.v * dt;
bird.rot = Math.min(bird.rot + dt * 7, Math.PI / 2);
if (bird.y >= SKY_H - BIRD_R) {
bird.y = SKY_H - BIRD_R;
state = STATE.OVER;
overT = 0;
sfx.swoosh();
}
return;
}
if (state === STATE.OVER) overT += dt;
}
// ---------- drawing ----------
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function drawSky() {
const g = ctx.createLinearGradient(0, 0, 0, SKY_H);
g.addColorStop(0, '#1b2a52');
g.addColorStop(0.55, '#3f5f9e');
g.addColorStop(1, '#7fa8d9');
ctx.fillStyle = g;
ctx.fillRect(0, 0, GW, SKY_H);
// stars (subtle, twinkling in the upper sky)
const t = clock;
for (const s of stars) {
const a = 0.25 + 0.25 * Math.sin(t * 2 + s.tw);
ctx.fillStyle = `rgba(255,255,240,${a.toFixed(3)})`;
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); ctx.fill();
}
// moon
ctx.fillStyle = 'rgba(255,248,220,0.9)';
ctx.beginPath(); ctx.arc(GW - 74, 78, 26, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(63,95,158,0.55)';
ctx.beginPath(); ctx.arc(GW - 84, 70, 22, 0, Math.PI * 2); ctx.fill();
}
function drawCloudShape(x, y, s, alpha) {
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.beginPath();
ctx.arc(x, y, 18 * s, 0, Math.PI * 2);
ctx.arc(x + 22 * s, y - 8 * s, 14 * s, 0, Math.PI * 2);
ctx.arc(x + 42 * s, y, 16 * s, 0, Math.PI * 2);
ctx.arc(x + 20 * s, y + 6 * s, 15 * s, 0, Math.PI * 2);
ctx.fill();
}
function drawParallax() {
// far clouds
for (const c of clouds) if (c.layer === 0) drawCloudShape(c.x, c.y, c.s * 0.8, 0.16);
// far mountain ridge (drifts with a slow phase tied to the game clock)
const t = clock;
ctx.fillStyle = 'rgba(38,58,102,0.85)';
ctx.beginPath();
ctx.moveTo(0, SKY_H);
for (let x = 0; x <= GW; x += 8) {
const y = SKY_H - 96 - Math.sin((x + t * 12) * 0.012) * 34 - Math.sin((x + t * 12) * 0.031) * 16;
ctx.lineTo(x, y);
}
ctx.lineTo(GW, SKY_H); ctx.closePath(); ctx.fill();
// near hills
ctx.fillStyle = 'rgba(52,84,128,0.95)';
ctx.beginPath();
ctx.moveTo(0, SKY_H);
for (let x = 0; x <= GW; x += 8) {
const y = SKY_H - 44 - Math.sin((x + t * 34) * 0.02) * 22 - Math.cos((x + t * 34) * 0.043) * 10;
ctx.lineTo(x, y);
}
ctx.lineTo(GW, SKY_H); ctx.closePath(); ctx.fill();
// near clouds
for (const c of clouds) if (c.layer === 1) drawCloudShape(c.x, c.y, c.s, 0.28);
}
function drawPipes() {
for (const p of pipes) {
const bodyG = ctx.createLinearGradient(p.x, 0, p.x + PIPE_W, 0);
bodyG.addColorStop(0, '#3f9d4e');
bodyG.addColorStop(0.35, '#6fce74');
bodyG.addColorStop(0.6, '#57b95f');
bodyG.addColorStop(1, '#2f7a3b');
// top pipe
ctx.fillStyle = bodyG;
ctx.fillRect(p.x + 4, 0, PIPE_W - 8, p.gapY - 24);
roundRect(p.x, p.gapY - 26, PIPE_W, 26, 5); ctx.fill();
// bottom pipe
ctx.fillRect(p.x + 4, p.gapY + p.gap + 24, PIPE_W - 8, SKY_H - p.gapY - p.gap - 24);
roundRect(p.x, p.gapY + p.gap, PIPE_W, 26, 5); ctx.fill();
// rim highlights
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.fillRect(p.x + 9, 0, 6, p.gapY - 26);
ctx.fillRect(p.x + 9, p.gapY + p.gap + 26, 6, SKY_H - p.gapY - p.gap - 26);
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.fillRect(p.x + PIPE_W - 12, 0, 6, p.gapY - 26);
ctx.fillRect(p.x + PIPE_W - 12, p.gapY + p.gap + 26, 6, SKY_H - p.gapY - p.gap - 26);
// cap shading
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(p.x + 3, p.gapY - 23, PIPE_W - 6, 5);
ctx.fillRect(p.x + 3, p.gapY + p.gap + 3, PIPE_W - 6, 5);
}
}
function drawGround() {
const g = ctx.createLinearGradient(0, SKY_H, 0, GH);
g.addColorStop(0, '#c9b26a');
g.addColorStop(0.12, '#9ac162');
g.addColorStop(0.16, '#77a94e');
g.addColorStop(1, '#5b8340');
ctx.fillStyle = g;
ctx.fillRect(0, SKY_H, GW, GROUND_H);
// striped turf edge
ctx.save();
ctx.beginPath(); ctx.rect(0, SKY_H, GW, 14); ctx.clip();
for (let x = groundX - 24; x < GW + 24; x += 24) {
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.beginPath();
ctx.moveTo(x, SKY_H); ctx.lineTo(x + 12, SKY_H);
ctx.lineTo(x + 4, SKY_H + 14); ctx.lineTo(x - 8, SKY_H + 14);
ctx.closePath(); ctx.fill();
}
ctx.restore();
ctx.fillStyle = 'rgba(0,0,0,0.20)';
ctx.fillRect(0, SKY_H, GW, 3);
// pebbles
ctx.fillStyle = 'rgba(0,0,0,0.10)';
for (let i = 0; i < 9; i++) {
const x = ((i * 97 + groundX * 2.5) % (GW + 40)) - 20;
ctx.beginPath(); ctx.arc(x, SKY_H + 34 + (i * 37 % 40), 3 + (i % 3), 0, Math.PI * 2); ctx.fill();
}
}
function drawBird() {
ctx.save();
ctx.translate(BIRD_X, bird.y);
ctx.rotate(bird.rot);
// body
const g = ctx.createRadialGradient(-4, -5, 3, 0, 0, BIRD_R + 4);
g.addColorStop(0, '#ffe38a');
g.addColorStop(0.6, '#ffc73d');
g.addColorStop(1, '#f0a020');
ctx.fillStyle = g;
ctx.beginPath(); ctx.ellipse(0, 0, BIRD_R + 2, BIRD_R, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(120,70,0,0.35)'; ctx.lineWidth = 1.5; ctx.stroke();
// wing (flaps). During pause, hold a neutral pose.
const flapping = (state === STATE.PLAY || state === STATE.READY) && !paused;
const wingA = flapping ? Math.sin(wingT * 22) * 0.9 - 0.2 : 0.6;
ctx.save();
ctx.translate(-3, 1);
ctx.rotate(wingA * 0.6);
ctx.fillStyle = '#f7b32b';
ctx.beginPath(); ctx.ellipse(-2, 2, 9, 6, -0.4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(120,70,0,0.3)'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
// belly
ctx.fillStyle = 'rgba(255,250,230,0.85)';
ctx.beginPath(); ctx.ellipse(1, 5, 8, 5.5, 0, 0, Math.PI * 2); ctx.fill();
// eye
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(6, -5, 5.4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(7.6, -5, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(8.4, -6, 0.9, 0, Math.PI * 2); ctx.fill();
// beak
ctx.fillStyle = '#ff7043';
ctx.beginPath();
ctx.moveTo(12, -1); ctx.lineTo(21, 1.5); ctx.lineTo(12, 5);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = 'rgba(150,40,0,0.4)'; ctx.lineWidth = 1; ctx.stroke();
ctx.restore();
}
function text(str, x, y, size, fill, align, weight) {
ctx.font = `${weight || 800} ${size}px 'Segoe UI', system-ui, sans-serif`;
ctx.textAlign = align || 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = Math.max(3, size * 0.14);
ctx.strokeStyle = 'rgba(20,30,50,0.85)';
ctx.strokeText(str, x, y);
ctx.fillStyle = fill;
ctx.fillText(str, x, y);
}
function drawSpeaker() {
const x = SPK.x, y = SPK.y + SPK.h / 2;
ctx.save();
ctx.globalAlpha = 0.9;
// speaker body
ctx.fillStyle = '#fff';
ctx.strokeStyle = 'rgba(20,30,50,0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y - 4);
ctx.lineTo(x + 6, y - 4);
ctx.lineTo(x + 12, y - 10);
ctx.lineTo(x + 12, y + 10);
ctx.lineTo(x + 6, y + 4);
ctx.lineTo(x, y + 4);
ctx.closePath();
ctx.fill(); ctx.stroke();
if (muted) {
// red slash
ctx.strokeStyle = '#ff5a5a'; ctx.lineWidth = 2.6;
ctx.beginPath(); ctx.moveTo(x + 15, y - 8); ctx.lineTo(x + 25, y + 8); ctx.stroke();
} else {
// sound waves
ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x + 13, y, 6, -0.8, 0.8); ctx.stroke();
ctx.beginPath(); ctx.arc(x + 13, y, 10, -0.7, 0.7); ctx.stroke();
}
ctx.restore();
}
function drawUI() {
if (state === STATE.PLAY || state === STATE.DYING) {
text(String(score), GW / 2, 64, 52, '#fff');
}
if (state === STATE.READY) {
text('FLAPPY FLIGHT', GW / 2, 130, 40, '#ffe066');
const pulse = 0.75 + 0.25 * Math.sin(clock * 3.33);
ctx.globalAlpha = pulse;
text('Tap / Click / Space to flap', GW / 2, SKY_H * 0.66, 20, '#fff', 'center', 600);
ctx.globalAlpha = 1;
if (best > 0) text('Best: ' + best, GW / 2, SKY_H * 0.66 + 34, 17, '#cfe3ff', 'center', 600);
text('P pause · M mute', GW / 2, SKY_H * 0.66 + 62, 13, 'rgba(255,255,255,0.7)', 'center', 600);
// hint arrow near bird
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
ctx.lineWidth = 3;
const ay = bird.y - 40 - Math.sin(clock * 4) * 5;
ctx.beginPath();
ctx.moveTo(BIRD_X, ay + 16); ctx.lineTo(BIRD_X, ay);
ctx.moveTo(BIRD_X - 7, ay + 8); ctx.lineTo(BIRD_X, ay);
ctx.lineTo(BIRD_X + 7, ay + 8);
ctx.stroke();
}
if (state === STATE.OVER) {
const slide = Math.min(overT / 0.35, 1);
const ease = 1 - Math.pow(1 - slide, 3);
ctx.save();
ctx.globalAlpha = ease;
ctx.translate(0, (1 - ease) * -30);
// panel
const pw = 280, ph = 190, px = (GW - pw) / 2, py = 150;
ctx.fillStyle = 'rgba(18,28,48,0.92)';
roundRect(px, py, pw, ph, 14); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 2; ctx.stroke();
text('GAME OVER', GW / 2, py + 36, 30, '#ff8a65');
text('SCORE', GW / 2 - 66, py + 82, 14, '#9fb4d8', 'center', 600);
text(String(score), GW / 2 - 66, py + 112, 34, '#fff');
text('BEST', GW / 2 + 66, py + 82, 14, '#9fb4d8', 'center', 600);
text(String(best), GW / 2 + 66, py + 112, 34, score >= best && score > 0 ? '#ffe066' : '#fff');
// medal
if (score >= 10) {
const medal = score >= 40 ? '#ffd700' : score >= 25 ? '#c0c0c0' : '#cd7f32';
ctx.fillStyle = medal;
ctx.beginPath(); ctx.arc(GW / 2, py + 148, 13, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(GW / 2 - 4, py + 144, 4, 0, Math.PI * 2); ctx.fill();
}
if (overT > 0.45) {
const pulse = 0.7 + 0.3 * Math.sin(clock * 3.57);
ctx.globalAlpha = ease * pulse;
text('Tap or press Space to restart', GW / 2, py + ph + 40, 18, '#fff', 'center', 600);
}
ctx.restore();
}
if (paused && state === STATE.PLAY) {
ctx.fillStyle = 'rgba(10,16,28,0.55)';
ctx.fillRect(0, 0, GW, GH);
text('PAUSED', GW / 2, SKY_H * 0.42, 40, '#ffe066');
const pulse = 0.7 + 0.3 * Math.sin(clock * 3.33);
ctx.globalAlpha = pulse;
text('Tap or press P to resume', GW / 2, SKY_H * 0.42 + 42, 18, '#fff', 'center', 600);
ctx.globalAlpha = 1;
}
drawSpeaker();
}
function draw() {
ctx.save();
if (shake > 0) {
const s = shake * shake * 9;
ctx.translate((Math.random() - 0.5) * s, (Math.random() - 0.5) * s);
}
drawSky();
drawParallax();
drawPipes();
drawGround();
drawBird();
ctx.restore();
drawUI();
if (flash > 0) {
ctx.fillStyle = `rgba(255,255,255,${(flash * 0.75).toFixed(3)})`;
ctx.fillRect(0, 0, GW, GH);
}
}
// ---------- main loop ----------
let last = performance.now();
function frame(now) {
let dt = (now - last) / 1000;
last = now;
if (dt > 0.05) dt = 0.05; // clamp after tab-switch
if (!paused) {
clock += dt;
update(dt);
}
draw();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>