← back to Flappy Sky
index.html
854 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0e1018" />
<meta name="description" content="Flappy Sky — a polished, self-contained Flappy-Bird-style game. Space, click, or tap to flap through neon gates." />
<title>Flappy Sky</title>
<style>
:root { --ink: #f8f8ff; }
html, body {
margin: 0;
padding: 0;
background: #0e1018;
overflow: hidden;
height: 100%;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-tap-highlight-color: transparent;
touch-action: none;
}
/* Portrait play-column: flappy is tuned for a phone-shaped field, so cap the
stage to a ~2:3 column centered in the window (letterboxed on desktop,
full-width on phones). JS sets #stage width; everything else is inset:0
to it so the HUD + overlay track the play area, not the whole window. */
#stage {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
height: 100%;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: radial-gradient(circle at 20% 20%, #586df2 0, #243046 40%, #0e1018 100%);
/* keeps focus outline from ever painting a box around the game */
outline: none;
}
/* --- overlay + ui --------------------------------------------------- */
#overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
user-select: none;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
box-sizing: border-box;
}
#ui {
color: var(--ink);
text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,0.8);
transition: opacity .25s ease, transform .25s ease;
max-width: 92vw;
}
#ui.pop { animation: pop .4s cubic-bezier(.2,1.3,.4,1) both; }
@keyframes pop { 0% { transform: scale(.85); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
#title {
font-size: clamp(34px, 8vw, 54px);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0 0 6px;
background: linear-gradient(180deg, #fff 0%, #b9d3ff 60%, #72fffb 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
filter: drop-shadow(0 3px 10px rgba(0,0,0,.6));
}
#subtitle { font-size: clamp(13px, 3.4vw, 16px); opacity: 0.85; margin-bottom: 22px; }
/* medal + result block on game over */
#result { display: none; margin-bottom: 18px; }
#result.show { display: block; }
#medal {
width: 92px; height: 92px; margin: 4px auto 10px;
border-radius: 50%;
display: grid; place-items: center;
font-size: 34px;
box-shadow: 0 0 0 4px rgba(255,255,255,.15), 0 8px 24px rgba(0,0,0,.55), inset 0 2px 10px rgba(255,255,255,.4);
position: relative;
}
#medal::after {
content: ""; position: absolute; inset: 8px; border-radius: 50%;
border: 2px dashed rgba(255,255,255,.35);
}
.scoreline { display:flex; gap: 26px; justify-content:center; margin: 6px 0 2px; }
.scoreline .k { font-size: 11px; letter-spacing:.18em; text-transform:uppercase; opacity:.65; }
.scoreline .v { font-size: 30px; font-weight: 800; line-height: 1.05; }
#newbest { font-size: 12px; font-weight: 800; letter-spacing:.12em; color:#ffe07a;
text-transform: uppercase; margin-top: 6px; opacity: 0; }
#newbest.show { opacity: 1; animation: flick .8s ease-in-out infinite alternate; }
@keyframes flick { from { filter: brightness(1);} to { filter: brightness(1.6);} }
#prompt {
font-size: clamp(14px, 3.6vw, 17px);
opacity: 0.95;
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 18px; border-radius: 999px;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.16);
backdrop-filter: blur(3px);
animation: breathe 1.8s ease-in-out infinite;
}
@keyframes breathe { 0%,100% { transform: translateY(0);} 50% { transform: translateY(-4px);} }
/* --- HUD ------------------------------------------------------------ */
#scoreTop {
position: absolute;
top: calc(14px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
color: #fff;
font-size: clamp(34px, 9vw, 52px);
font-weight: 900;
text-shadow: 0 3px 0 rgba(0,0,0,.35), 0 0 18px rgba(120,180,255,.5);
pointer-events: none;
transition: transform .08s ease;
}
#scoreTop.bump { transform: translateX(-50%) scale(1.28); }
#scoreTop.hidden { display: none; }
#best {
position: absolute;
top: calc(16px + env(safe-area-inset-top));
right: calc(14px + env(safe-area-inset-right));
color: rgba(255,255,255,.8);
font-size: 13px; font-weight: 700; letter-spacing:.05em;
text-shadow: 0 0 6px rgba(0,0,0,.8);
pointer-events: none;
}
#best.hidden { display: none; }
/* control buttons (mute / pause) */
#controls {
position: absolute;
top: calc(12px + env(safe-area-inset-top));
left: calc(12px + env(safe-area-inset-left));
display: flex; gap: 8px;
}
.btn {
width: 38px; height: 38px; border-radius: 10px;
border: 1px solid rgba(255,255,255,.18);
background: rgba(255,255,255,.07);
color: #fff; font-size: 17px; cursor: pointer;
display: grid; place-items: center;
backdrop-filter: blur(4px);
transition: background .15s ease, transform .1s ease;
-webkit-user-select: none; user-select: none;
}
.btn:hover { background: rgba(255,255,255,.16); }
.btn:active { transform: scale(.9); }
#smallInfo {
position: absolute;
bottom: calc(9px + env(safe-area-inset-bottom));
right: calc(12px + env(safe-area-inset-right));
font-size: 11px;
color: rgba(255,255,255,0.4);
text-shadow: 0 0 4px rgba(0,0,0,0.8);
pointer-events: none;
}
/* pause veil */
#pauseVeil {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
background: rgba(6,8,16,.55); backdrop-filter: blur(2px);
color: #fff; font-size: clamp(22px, 6vw, 34px); font-weight: 800;
letter-spacing: .12em; text-transform: uppercase; pointer-events: none;
}
#pauseVeil.show { display: flex; }
.hidden { display: none; }
</style>
</head>
<body>
<div id="stage">
<canvas id="game" tabindex="-1" aria-label="Flappy Sky game canvas"></canvas>
<div id="scoreTop" class="hidden"><span id="scoreVal">0</span></div>
<div id="best" class="hidden">Best <span id="bestVal">0</span></div>
<div id="controls">
<button id="muteBtn" class="btn" title="Mute (M)" aria-label="Toggle sound">🔊</button>
<button id="pauseBtn" class="btn" title="Pause (P)" aria-label="Pause game">⏸</button>
</div>
<div id="overlay">
<div id="ui" class="pop">
<div id="title">FLAPPY SKY</div>
<div id="subtitle">Flap through the neon gates. How far can you fly?</div>
<div id="result">
<div id="medal">🥇</div>
<div class="scoreline">
<div><div class="k">Score</div><div class="v" id="rScore">0</div></div>
<div><div class="k">Best</div><div class="v" id="rBest">0</div></div>
</div>
<div id="newbest">★ New Best! ★</div>
</div>
<div id="prompt">Press <b>Space</b>, Click, or Tap to Start</div>
</div>
</div>
<div id="pauseVeil">Paused</div>
<div id="smallInfo">Space / Click / Tap · P pause · M mute</div>
</div><!-- /#stage -->
<script>
(function () {
"use strict";
const canvas = document.getElementById("game");
const stage = document.getElementById("stage");
const ctx = canvas.getContext("2d", { alpha: false });
// ---- DOM refs -------------------------------------------------------
const overlay = document.getElementById("overlay");
const ui = document.getElementById("ui");
const uiPrompt = document.getElementById("prompt");
const result = document.getElementById("result");
const medalEl = document.getElementById("medal");
const rScore = document.getElementById("rScore");
const rBest = document.getElementById("rBest");
const newbest = document.getElementById("newbest");
const scoreTop = document.getElementById("scoreTop");
const scoreVal = document.getElementById("scoreVal");
const bestBox = document.getElementById("best");
const bestVal = document.getElementById("bestVal");
const pauseVeil = document.getElementById("pauseVeil");
const muteBtn = document.getElementById("muteBtn");
const pauseBtn = document.getElementById("pauseBtn");
const reduceMotion = window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- viewport -------------------------------------------------------
let W, H, scale, groundH;
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
// Cap the playfield to a ~2:3 portrait column so the phone-tuned physics
// stay playable on wide desktop windows (letterboxed) while phones still
// fill their full width. #stage is centered via CSS; the canvas fills it.
H = window.innerHeight;
W = Math.min(window.innerWidth, Math.round(H * 0.66));
stage.style.width = W + "px";
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
scale = Math.min(Math.max(Math.min(W, H) / 600, 0.6), 1.6);
groundH = Math.round(Math.min(90 * scale, H * 0.14));
initBackground();
// keep the idle bird sensibly placed after a rotate/resize
if (bird && state !== "playing") { bird.x = W * 0.3; bird.baseY = H * 0.42; }
}
// ---- tuning ---------------------------------------------------------
const GRAVITY = 1500; // px/s^2
const FLAP = -450; // impulse
const PIPE_W = 84; // pipe width (px, scaled at draw)
const PIPE_INTERVAL_START = 1500; // ms between pipes
const PIPE_INTERVAL_MIN = 950;
const GAP_START = 200; // px gap (pre-scale)
const GAP_MIN = 138;
const SPEED_START = 200; // px/s
const SPEED_MAX = 400;
const RAMP = 25; // score at which difficulty maxes out
const FIXED_DT = 1 / 120; // physics step
// ---- state ----------------------------------------------------------
let state = "ready"; // ready | playing | paused | gameover
let bird, pipes, particles, stars, hills, ground;
let score = 0, best = 0;
let pipeInterval, gapSize, speed;
let timeSincePipe = 0;
let shake = 0, flashAlpha = 0, hitStop = 0;
let lastTime = 0, acc = 0;
let muted = false;
let bgScroll = 0;
try { best = parseInt(localStorage.getItem("flappy_sky_best"), 10) || 0; } catch (e) {}
try { muted = localStorage.getItem("flappy_sky_muted") === "1"; } catch (e) {}
bestVal.textContent = best;
// ---- audio (WebAudio, no assets) -----------------------------------
let actx = null;
function audio() {
if (!actx) {
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { actx = null; }
}
if (actx && actx.state === "suspended") actx.resume();
return actx;
}
function tone(freq, dur, type, vol, slideTo) {
if (muted) return;
const a = audio(); if (!a) return;
const t0 = a.currentTime;
const osc = a.createOscillator();
const g = a.createGain();
osc.type = type || "square";
osc.frequency.setValueAtTime(freq, t0);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(Math.max(1, slideTo), t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol || 0.12, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(a.destination);
osc.start(t0); osc.stop(t0 + dur + 0.02);
}
const sfx = {
flap: () => tone(560, 0.12, "square", 0.10, 780),
score: () => { tone(720, 0.09, "triangle", 0.12); setTimeout(() => tone(960, 0.10, "triangle", 0.12), 70); },
hit: () => { tone(200, 0.18, "sawtooth", 0.16, 80); },
die: () => { tone(340, 0.5, "sine", 0.12, 60); },
swoosh:() => tone(300, 0.14, "sine", 0.05, 120)
};
// ---- background -----------------------------------------------------
function initBackground() {
stars = [];
const starCount = Math.round((W * H) / 16000);
for (let i = 0; i < starCount; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * (H - groundH) * 0.9,
r: Math.random() * 1.5 + 0.4,
speed: 6 + Math.random() * 12,
phase: Math.random() * Math.PI * 2,
tw: 1.2 + Math.random() * 1.6
});
}
hills = [
{ base: 0.70, amp: 42 * scale, freq: 0.0042, speed: 16, off: 0, color: "#141a33" },
{ base: 0.78, amp: 58 * scale, freq: 0.0030, speed: 30, off: 240, color: "#0c1024" }
];
ground = { off: 0 };
}
// ---- game objects ---------------------------------------------------
function resetGame() {
bird = { x: W * 0.3, baseY: H * 0.42, y: H * 0.42, vy: 0, r: 16 * scale, rot: 0, flapT: 0 };
pipes = [];
particles = [];
timeSincePipe = 0;
score = 0;
scoreVal.textContent = "0";
pipeInterval = PIPE_INTERVAL_START;
gapSize = GAP_START * scale;
speed = SPEED_START;
shake = 0; flashAlpha = 0; hitStop = 0;
}
function difficulty() {
const t = Math.min(score / RAMP, 1);
pipeInterval = PIPE_INTERVAL_START - (PIPE_INTERVAL_START - PIPE_INTERVAL_MIN) * t;
gapSize = (GAP_START - (GAP_START - GAP_MIN) * t) * scale;
speed = SPEED_START + (SPEED_MAX - SPEED_START) * t;
}
function spawnPipe() {
const playH = H - groundH;
const margin = Math.max(50 * scale, playH * 0.09);
const minTop = margin;
const maxTop = playH - margin - gapSize;
const top = maxTop <= minTop ? (playH - gapSize) / 2
: minTop + Math.random() * (maxTop - minTop);
// hue drifts a touch over the run so later pipes read hotter
const hue = 165 - Math.min(score, 40) * 1.2;
pipes.push({ x: W + PIPE_W, top: top, gap: gapSize, scored: false, hue: hue });
}
function addParticles(x, y, count, hue, power) {
if (reduceMotion) count = Math.ceil(count / 3);
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const mag = (Math.random() * 150 + 40) * scale * (power || 1);
particles.push({
x, y,
vx: Math.cos(ang) * mag,
vy: Math.sin(ang) * mag - 40 * scale,
life: 0.5 + Math.random() * 0.35, age: 0,
size: (Math.random() * 3 + 2) * scale,
hue: hue + (Math.random() * 30 - 15)
});
}
}
// ---- input (single unified path, dedupes touch+mouse) --------------
let lastFlap = 0;
function flap() {
audio(); // unlock on first gesture
const now = performance.now();
if (now - lastFlap < 55) return; // debounce double-fire (touch->mouse)
lastFlap = now;
if (state === "ready") {
startGame();
doFlap();
} else if (state === "playing") {
doFlap();
} else if (state === "paused") {
resume();
} else if (state === "gameover") {
// small guard so the death tap doesn't instantly restart
if (now - gameoverAt > 350) toReady();
}
}
function doFlap() {
bird.vy = FLAP;
bird.flapT = 1;
addParticles(bird.x - bird.r * 0.8, bird.y + bird.r * 0.4, 6, 205, 0.7);
sfx.flap();
}
function startGame() {
state = "playing";
ui.classList.remove("pop");
overlay.classList.add("hidden");
scoreTop.classList.remove("hidden");
bestBox.classList.remove("hidden");
result.classList.remove("show");
sfx.swoosh();
}
function toReady() {
state = "ready";
resetGame();
result.classList.remove("show");
newbest.classList.remove("show");
uiPrompt.textContent = "Press Space, Click, or Tap to Start";
overlay.classList.remove("hidden");
ui.classList.remove("pop"); void ui.offsetWidth; ui.classList.add("pop");
scoreTop.classList.add("hidden");
bestBox.classList.add("hidden");
}
let gameoverAt = 0;
function gameOver() {
if (state !== "playing") return;
state = "gameover";
gameoverAt = performance.now();
hitStop = 0.08;
shake = reduceMotion ? 0 : 16;
flashAlpha = reduceMotion ? 0.25 : 0.6;
addParticles(bird.x, bird.y, reduceMotion ? 10 : 30, 40, 1.5);
sfx.hit(); sfx.die();
const isBest = score > best;
if (isBest) { best = score; try { localStorage.setItem("flappy_sky_best", best); } catch (e) {} }
bestVal.textContent = best;
rScore.textContent = score;
rBest.textContent = best;
setMedal(score);
result.classList.add("show");
newbest.classList.toggle("show", isBest);
uiPrompt.textContent = "Click / Tap / Space to Play Again";
// reveal overlay a beat after the death punch
setTimeout(() => {
if (state === "gameover") {
overlay.classList.remove("hidden");
ui.classList.remove("pop"); void ui.offsetWidth; ui.classList.add("pop");
}
}, 260);
}
function setMedal(s) {
let icon = "🥉", bg = "linear-gradient(160deg,#c9865b,#8a5a34)";
if (s >= 40) { icon = "💎"; bg = "linear-gradient(160deg,#8ef7ff,#2aa7c9)"; }
else if (s >= 25) { icon = "🥇"; bg = "linear-gradient(160deg,#ffe27a,#d3a520)"; }
else if (s >= 12) { icon = "🥈"; bg = "linear-gradient(160deg,#e8eef7,#9aa7bd)"; }
else if (s >= 4) { icon = "🥉"; bg = "linear-gradient(160deg,#c9865b,#8a5a34)"; }
else { icon = "🍃"; bg = "linear-gradient(160deg,#7f8aa3,#4a5570)"; }
medalEl.textContent = icon;
medalEl.style.background = bg;
}
// ---- pause ----------------------------------------------------------
function togglePause() {
if (state === "playing") pause();
else if (state === "paused") resume();
}
function pause() {
if (state !== "playing") return;
state = "paused";
pauseVeil.classList.add("show");
pauseBtn.textContent = "▶";
}
function resume() {
if (state !== "paused") return;
state = "playing";
pauseVeil.classList.remove("show");
pauseBtn.textContent = "⏸";
lastTime = performance.now(); acc = 0; // avoid a physics jump
}
function toggleMute() {
muted = !muted;
muteBtn.textContent = muted ? "🔇" : "🔊";
try { localStorage.setItem("flappy_sky_muted", muted ? "1" : "0"); } catch (e) {}
if (!muted) sfx.swoosh();
}
muteBtn.textContent = muted ? "🔇" : "🔊";
// ---- listeners ------------------------------------------------------
window.addEventListener("resize", resize);
window.addEventListener("orientationchange", resize);
document.addEventListener("keydown", (e) => {
if (e.code === "Space" || e.code === "ArrowUp" || e.code === "Enter") {
e.preventDefault(); flap();
} else if (e.code === "KeyP") {
e.preventDefault(); togglePause();
} else if (e.code === "KeyM") {
e.preventDefault(); toggleMute();
}
});
// pointer handles mouse + touch + pen uniformly; debounce covers the rest
canvas.addEventListener("pointerdown", (e) => { e.preventDefault(); flap(); });
overlay.addEventListener("pointerdown", (e) => { e.preventDefault(); flap(); });
// fallback for browsers without pointer events
if (!window.PointerEvent) {
canvas.addEventListener("mousedown", flap);
canvas.addEventListener("touchstart", (e) => { e.preventDefault(); flap(); }, { passive: false });
}
muteBtn.addEventListener("pointerdown", (e) => { e.stopPropagation(); e.preventDefault(); toggleMute(); });
pauseBtn.addEventListener("pointerdown", (e) => { e.stopPropagation(); e.preventDefault(); togglePause(); });
// auto-pause when the tab/window loses focus mid-game
document.addEventListener("visibilitychange", () => { if (document.hidden) pause(); });
window.addEventListener("blur", () => pause());
// ---- physics --------------------------------------------------------
function step(dt) {
bgScroll += dt;
// parallax always animates (even on menus) for a living background
for (const s of stars) {
s.x -= s.speed * dt;
if (s.x < -4) { s.x = W + 4; s.y = Math.random() * (H - groundH) * 0.9; }
s.phase += dt * s.tw;
}
for (const h of hills) h.off += h.speed * dt;
if (state === "playing") {
ground.off = (ground.off + speed * dt) % 48;
difficulty();
bird.flapT = Math.max(0, bird.flapT - dt * 6);
bird.vy += GRAVITY * dt;
bird.y += bird.vy * dt;
bird.rot = Math.max(-0.55, Math.min(1.4, Math.atan2(bird.vy, 420)));
// soft ceiling — clamp instead of instant death (classic feel)
if (bird.y - bird.r < 0) { bird.y = bird.r; if (bird.vy < 0) bird.vy = 0; }
timeSincePipe += dt * 1000;
if (timeSincePipe >= pipeInterval) { timeSincePipe -= pipeInterval; spawnPipe(); }
const bx = bird.x, by = bird.y, br = bird.r * 0.82;
const pw = PIPE_W * scale;
for (let i = pipes.length - 1; i >= 0; i--) {
const p = pipes[i];
p.x -= speed * dt;
if (p.x + pw < -20) { pipes.splice(i, 1); continue; }
if (!p.scored && p.x + pw < bx) {
p.scored = true; score++;
scoreVal.textContent = score;
scoreTop.classList.remove("bump"); void scoreTop.offsetWidth; scoreTop.classList.add("bump");
sfx.score();
}
// circle-vs-rect collision (accurate)
if (hitPipe(bx, by, br, p, pw)) { gameOver(); return; }
}
// ground death
if (by + br > H - groundH) { bird.y = H - groundH - br; gameOver(); return; }
} else if (state === "ready" || state === "gameover") {
// clean bob around a fixed base — no accumulating drift
const t = bgScroll;
bird.y = bird.baseY + Math.sin(t * 2.4) * 8 * scale;
bird.rot = Math.sin(t * 1.6) * 0.18;
}
// particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.age += dt;
if (p.age >= p.life) { particles.splice(i, 1); continue; }
p.x += p.vx * dt; p.y += p.vy * dt;
p.vy += 520 * dt; p.vx *= 0.986;
}
if (shake > 0) shake = Math.max(0, shake - dt * 60);
if (flashAlpha > 0) flashAlpha = Math.max(0, flashAlpha - dt * 2.2);
}
function hitPipe(cx, cy, r, p, pw) {
// top rect: (p.x,0,pw,p.top) ; bottom rect: (p.x,p.top+gap,pw,rest)
const bottomY = p.top + p.gap;
return rectHit(cx, cy, r, p.x, 0, pw, p.top) ||
rectHit(cx, cy, r, p.x, bottomY, pw, (H - groundH) - bottomY);
}
function rectHit(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;
}
// ---- rendering ------------------------------------------------------
function drawBackground() {
// Opaque full-frame sky. This is also the per-frame CLEAR — the canvas is
// created with { alpha: false }, so the CSS background never shows through
// and a translucent fill here would let every prior frame ghost through
// (the bird/pipes/particles smeared into a trail). Paint the sky solid.
const grd = ctx.createRadialGradient(W * 0.2, H * 0.2, 0, W * 0.2, H * 0.2, Math.max(W, H));
grd.addColorStop(0, "#586df2");
grd.addColorStop(0.4, "#243046");
grd.addColorStop(1, "#0e1018");
ctx.fillStyle = grd;
ctx.fillRect(0, 0, W, H);
for (const s of stars) {
const a = 0.25 + 0.55 * (0.5 + 0.5 * Math.sin(s.phase));
ctx.fillStyle = "rgba(230,240,255," + a.toFixed(3) + ")";
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.2832); ctx.fill();
}
// haze band
const cy = H * 0.3;
const cg = ctx.createLinearGradient(0, cy - 80, 0, cy + 80);
cg.addColorStop(0, "rgba(160,190,255,0)");
cg.addColorStop(0.4, "rgba(150,180,255,0.07)");
cg.addColorStop(1, "rgba(80,110,170,0)");
ctx.fillStyle = cg;
ctx.fillRect(0, cy - 80, W, 160);
for (const h of hills) {
const baseY = (H - groundH) * h.base;
ctx.fillStyle = h.color;
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W + 24; x += 20) {
const y = baseY + Math.sin((x + h.off) * h.freq) * h.amp;
ctx.lineTo(x, y);
}
ctx.lineTo(W, H); ctx.closePath(); ctx.fill();
}
}
function drawPipes() {
const pw = PIPE_W * scale;
const lip = 14 * scale;
const playH = H - groundH;
for (const p of pipes) {
const bottomY = p.top + p.gap;
const c1 = "hsl(" + p.hue + ",90%,55%)";
const c2 = "hsl(" + (p.hue + 18) + ",95%,72%)";
const c3 = "hsl(" + (p.hue - 8) + ",85%,42%)";
ctx.save();
ctx.shadowColor = "hsla(" + p.hue + ",100%,60%,0.6)";
ctx.shadowBlur = reduceMotion ? 0 : 16;
drawPipeBody(p.x, 0, pw, p.top, c1, c2, c3, lip, true);
drawPipeBody(p.x, bottomY, pw, playH - bottomY, c1, c2, c3, lip, false);
ctx.restore();
}
}
function drawPipeBody(x, y, w, h, c1, c2, c3, lip, isTop) {
if (h <= 0) return;
const g = ctx.createLinearGradient(x, 0, x + w, 0);
g.addColorStop(0, c3); g.addColorStop(0.18, c1);
g.addColorStop(0.5, c2); g.addColorStop(0.82, c1); g.addColorStop(1, c3);
ctx.fillStyle = g;
// main shaft
ctx.fillRect(x, y, w, h);
// lip (mouth end)
const ly = isTop ? y + h - lip : y;
const lw = w + 8 * scale;
ctx.fillRect(x - 4 * scale, ly, lw, lip);
// glossy highlight
ctx.fillStyle = "rgba(255,255,255,0.22)";
ctx.fillRect(x + w * 0.16, y, w * 0.1, h);
}
function drawGround() {
const gy = H - groundH;
const g = ctx.createLinearGradient(0, gy, 0, H);
g.addColorStop(0, "#3a3f2e");
g.addColorStop(0.12, "#4a5136");
g.addColorStop(1, "#20241a");
ctx.fillStyle = g;
ctx.fillRect(0, gy, W, groundH);
// top rim
ctx.fillStyle = "rgba(120,200,150,0.35)";
ctx.fillRect(0, gy, W, 3 * scale);
// scrolling hatch
ctx.strokeStyle = "rgba(0,0,0,0.18)";
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = -48 + (-ground.off); x < W + 48; x += 24) {
ctx.moveTo(x, gy + 6 * scale);
ctx.lineTo(x + 14, H);
}
ctx.stroke();
}
function drawBird() {
const b = bird;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.rot);
const R = b.r;
// body
const bg = ctx.createRadialGradient(-R * 0.2, -R * 0.2, R * 0.2, 0, 0, R * 1.2);
bg.addColorStop(0, "#fffbe6"); bg.addColorStop(0.5, "#ffe07a"); bg.addColorStop(1, "#ff9f3f");
ctx.fillStyle = bg;
ctx.beginPath(); ctx.ellipse(0, 0, R * 1.15, R * 0.92, 0, 0, 6.2832); ctx.fill();
// belly
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.beginPath(); ctx.ellipse(-R * 0.1, R * 0.12, R * 0.68, R * 0.56, 0, 0, 6.2832); ctx.fill();
// wing — flap amplitude tied to flapT (springs on each flap)
const wingBase = state === "playing" ? (0.35 - bird.flapT * 1.3)
: Math.sin(bgScroll * 8) * 0.4;
ctx.save();
ctx.translate(-R * 0.1, 0);
ctx.rotate(-0.7 + wingBase);
const wg = ctx.createLinearGradient(-R * 0.2, -R * 0.4, R * 0.5, R * 0.6);
wg.addColorStop(0, "#ffe7a3"); wg.addColorStop(1, "#ffb25a");
ctx.fillStyle = wg;
ctx.beginPath(); ctx.ellipse(0, 0, R * 0.92, R * 0.5, 0, 0, 6.2832); ctx.fill();
ctx.strokeStyle = "rgba(180,110,40,0.35)"; ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
// eye
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(R * 0.42, -R * 0.22, R * 0.26, 0, 6.2832); ctx.fill();
ctx.fillStyle = "#1c1f2b";
ctx.beginPath(); ctx.arc(R * 0.5, -R * 0.22, R * 0.14, 0, 6.2832); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.beginPath(); ctx.arc(R * 0.55, -R * 0.26, R * 0.05, 0, 6.2832); ctx.fill();
// beak
ctx.fillStyle = "#ff9f2f";
ctx.beginPath();
ctx.moveTo(R * 0.92, -R * 0.05);
ctx.lineTo(R * 1.42, R * 0.14);
ctx.lineTo(R * 0.92, R * 0.3);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#e07a1e";
ctx.beginPath();
ctx.moveTo(R * 0.92, R * 0.14);
ctx.lineTo(R * 1.42, R * 0.14);
ctx.lineTo(R * 0.92, R * 0.3);
ctx.closePath(); ctx.fill();
// tail
ctx.fillStyle = "#ffb562";
ctx.beginPath();
ctx.moveTo(-R * 1.05, -R * 0.12);
ctx.lineTo(-R * 1.55, -R * 0.32);
ctx.lineTo(-R * 1.2, R * 0.18);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawParticles() {
for (const p of particles) {
const t = p.age / p.life;
ctx.globalAlpha = (1 - t) * 0.9;
ctx.fillStyle = "hsl(" + p.hue + ",100%,72%)";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (1 - t * 0.5), 0, 6.2832);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function render() {
let sx = 0, sy = 0;
if (shake > 0) {
sx = (Math.random() * 2 - 1) * shake;
sy = (Math.random() * 2 - 1) * shake;
}
ctx.save();
ctx.translate(sx, sy);
drawBackground();
drawPipes();
drawGround();
drawParticles();
drawBird();
ctx.restore();
if (flashAlpha > 0) {
ctx.fillStyle = "rgba(255,80,80," + flashAlpha.toFixed(3) + ")";
ctx.fillRect(0, 0, W, H);
}
}
// ---- main loop (fixed-step physics, smooth render) ------------------
function loop(ts) {
if (!lastTime) lastTime = ts;
let frame = (ts - lastTime) / 1000;
lastTime = ts;
if (frame > 0.1) frame = 0.1; // clamp big gaps (tab restore)
if (hitStop > 0) { hitStop -= frame; }
else {
acc += frame;
while (acc >= FIXED_DT) { step(FIXED_DT); acc -= FIXED_DT; }
}
render();
requestAnimationFrame(loop);
}
// ---- boot -----------------------------------------------------------
resize();
resetGame();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>