Created
August 7, 2025 14:55
-
-
Save jkot/90f2ade508b2e90ac7bd3703fada5835 to your computer and use it in GitHub Desktop.
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,maximum-scale=1,user-scalable=no" /> | |
| <title>Flappy Getaway — One-File Game</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| } | |
| html, body { | |
| margin: 0; | |
| height: 100%; | |
| background: #0b0f14 radial-gradient(1200px 600px at 80% -20%, #1a2230 0%, transparent 60%) no-repeat; | |
| font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif; | |
| overflow: hidden; | |
| user-select: none; | |
| -webkit-touch-callout: none; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| #game { | |
| display: block; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| .ui { | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| display: grid; | |
| place-items: start stretch; | |
| } | |
| .topbar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: clamp(8px, 2.2vmin, 18px); | |
| color: #dbe6ff; | |
| font-weight: 700; | |
| text-shadow: 0 1px 0 #000, 0 0 12px rgba(123,167,255,0.4); | |
| } | |
| .chip { | |
| background: rgba(10,16,28,0.6); | |
| border: 1px solid rgba(114,169,255,0.25); | |
| padding: .35em .7em; | |
| border-radius: 9px; | |
| backdrop-filter: blur(4px); | |
| box-shadow: 0 2px 14px rgba(0,0,0,.35) inset, 0 0 20px rgba(102,153,255,.12); | |
| } | |
| .center { | |
| pointer-events: none; | |
| position: fixed; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| text-align: center; | |
| color: #e9f1ff; | |
| padding: 24px; | |
| } | |
| .card { | |
| pointer-events: auto; | |
| max-width: min(720px, 92vw); | |
| background: linear-gradient(180deg, rgba(10,16,28,0.85), rgba(8,12,20,0.8)); | |
| border: 1px solid rgba(114,169,255,0.25); | |
| border-radius: 14px; | |
| padding: clamp(16px, 3vmin, 28px); | |
| box-shadow: 0 14px 50px rgba(0,0,0,.5), 0 0 80px rgba(114,169,255,.15); | |
| } | |
| .title { | |
| font-size: clamp(28px, 6.3vmin, 52px); | |
| margin: 0 0 .25em; | |
| letter-spacing: .5px; | |
| } | |
| .subtitle { | |
| margin: 0 0 1em; | |
| opacity: .9; | |
| } | |
| .row { | |
| display: flex; | |
| gap: 12px; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| } | |
| .btn { | |
| cursor: pointer; | |
| background: linear-gradient(180deg, #2b6cf5, #1946c9); | |
| color: #fff; | |
| border: 0; | |
| padding: .75em 1.1em; | |
| border-radius: 12px; | |
| font-weight: 800; | |
| letter-spacing: .4px; | |
| box-shadow: 0 10px 30px rgba(43,108,245,.35); | |
| transition: transform .06s ease; | |
| } | |
| .btn:active { transform: translateY(1px) scale(.99); } | |
| .kbd { | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| background: rgba(255,255,255,.08); | |
| border: 1px solid rgba(255,255,255,.15); | |
| border-bottom-width: 2px; | |
| padding: .15em .45em; | |
| border-radius: 6px; | |
| } | |
| .footer { | |
| position: fixed; | |
| bottom: 6px; left: 6px; right: 6px; | |
| text-align: center; | |
| color: #9fb4df; | |
| font-size: 12px; | |
| opacity: .7; | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div class="ui"> | |
| <div class="topbar"> | |
| <div class="chip" id="scoreChip">Score: 0</div> | |
| <div class="chip" id="wantedChip">Wanted: 0%</div> | |
| <div class="chip" id="bestChip">Best: 0</div> | |
| </div> | |
| </div> | |
| <div class="center" id="menu"> | |
| <div class="card"> | |
| <h1 class="title">Flappy Getaway</h1> | |
| <p class="subtitle">Dodge the skyline, grab cash, and keep your Wanted level down.</p> | |
| <div style="margin: .6em 0 1.1em; color:#bcd2ff;"> | |
| Controls: | |
| <span class="kbd">Space</span> or <span class="kbd">Click / Tap</span> to flap, | |
| <span class="kbd">P</span> to pause. | |
| </div> | |
| <div class="row" style="margin-top: .4em;"> | |
| <button class="btn" id="startBtn">Start</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="center" id="gameover" style="display:none;"> | |
| <div class="card"> | |
| <h2 class="title" style="font-size: clamp(24px,5.2vmin,42px);">Busted!</h2> | |
| <p class="subtitle" id="finalStats"></p> | |
| <div class="row" style="margin-top:.4em;"> | |
| <button class="btn" id="retryBtn">Retry</button> | |
| <button class="btn" id="menuBtn" style="background:linear-gradient(180deg,#596a85,#3c485e); box-shadow:0 10px 30px rgba(60,72,94,.3);">Menu</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer">This is an original mini-game inspired by chase vibes and flappy mechanics — no assets from any existing franchise.</div> | |
| <script> | |
| /* | |
| Flappy Getaway — single-file canvas game | |
| - Dodge building gaps (Flappy-style). | |
| - Collect cash to score + reduce Wanted. | |
| - Wanted rises over time and ramps difficulty (speed + spawn rate + siren). | |
| - Desktop + Mobile. Minimal sounds via WebAudio after first interaction. | |
| */ | |
| // Canvas setup | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d', { alpha: false }); | |
| // UI elements | |
| const scoreChip = document.getElementById('scoreChip'); | |
| const wantedChip = document.getElementById('wantedChip'); | |
| const bestChip = document.getElementById('bestChip'); | |
| const menuOverlay = document.getElementById('menu'); | |
| const gameoverOverlay = document.getElementById('gameover'); | |
| const startBtn = document.getElementById('startBtn'); | |
| const retryBtn = document.getElementById('retryBtn'); | |
| const menuBtn = document.getElementById('menuBtn'); | |
| const finalStats = document.getElementById('finalStats'); | |
| // Persistent best | |
| const LS_KEY = 'flappyGetawayBest'; | |
| let bestScore = +localStorage.getItem(LS_KEY) || 0; | |
| bestChip.textContent = 'Best: ' + bestScore; | |
| // World / game state | |
| let W = 0, H = 0, DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); | |
| let lastT = 0; | |
| let running = true; | |
| let state = 'menu'; // 'menu' | 'play' | 'pause' | 'dead' | |
| let sirenPhase = 0; | |
| // Player | |
| const player = { | |
| x: 0, y: 0, w: 52, h: 28, vy: 0, rot: 0, | |
| tint: '#9fd3ff' | |
| }; | |
| // Physics, difficulty | |
| let gravity = 1800; // px/s^2 | |
| let jumpVel = -560; // px/s | |
| let scrollSpeed = 220; // px/s base, scales with wanted | |
| let spawnInterval = 1.25; // seconds, scales with wanted | |
| let timeSinceSpawn = 0; | |
| let wanted = 0; // 0..100 | |
| let score = 0; | |
| let passedCount = 0; | |
| // World objects | |
| const pipes = []; // {x, w, gapY, gapH, passed, coin:{y, taken}} | |
| const particles = []; // lightweight visual bits | |
| const skyline1 = []; // parallax | |
| const skyline2 = []; | |
| // Audio | |
| let AudioCtx = window.AudioContext || window.webkitAudioContext; | |
| let actx = null; | |
| // Input | |
| let flapQueued = false; | |
| // Resize and init | |
| function resize() { | |
| DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); | |
| canvas.width = Math.floor(window.innerWidth * DPR); | |
| canvas.height = Math.floor(window.innerHeight * DPR); | |
| canvas.style.width = window.innerWidth + 'px'; | |
| canvas.style.height = window.innerHeight + 'px'; | |
| W = canvas.width; | |
| H = canvas.height; | |
| // Rebuild skylines for parallax | |
| buildSkyline(skyline1, 10, H*0.35, H*0.55, 0.55, '#0e1624'); | |
| buildSkyline(skyline2, 12, H*0.25, H*0.45, 0.8, '#121c2f'); | |
| } | |
| window.addEventListener('resize', resize); | |
| // Skyline helpers | |
| function buildSkyline(arr, count, minH, maxH, coverage, color) { | |
| arr.length = 0; | |
| let x = 0; | |
| const maxW = W * coverage; | |
| const baseY = H * 0.72; | |
| while (x < W + 200) { | |
| const bw = rand(60, 160) * (coverage); | |
| const bh = rand(minH, maxH); | |
| arr.push({ x, y: baseY - bh, w: bw, h: bh, color }); | |
| x += bw + rand(18, 46); | |
| } | |
| } | |
| function drawSkyline(arr, speedFactor) { | |
| ctx.save(); | |
| for (const b of arr) { | |
| b.x -= (scrollSpeed * speedFactor) * dt; | |
| if (b.x + b.w < -50) { | |
| // recycle | |
| const last = arr.reduce((m, cur) => cur.x > m.x ? cur : m, arr[0]); | |
| b.x = last.x + last.w + rand(18, 46); | |
| b.w = rand(60, 160) * speedFactor * 1.2; | |
| b.h = clamp(b.h + rand(-40, 40), H*0.2, H*0.6); | |
| b.y = (H * 0.72) - b.h; | |
| } | |
| ctx.fillStyle = b.color; | |
| roundRect(b.x, b.y, b.w, b.h, 6); | |
| ctx.fill(); | |
| // windows speckles | |
| ctx.globalAlpha = 0.05; | |
| ctx.fillStyle = '#9fc3ff'; | |
| for (let i = 0; i < 6; i++) { | |
| const ww = 6, wh = 12; | |
| const wx = b.x + rand(8, b.w-14); | |
| const wy = b.y + rand(8, b.h-20); | |
| ctx.fillRect(wx, wy, ww, wh); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| ctx.restore(); | |
| } | |
| // Utility | |
| function rand(min, max) { return Math.random() * (max - min) + min; } | |
| function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); } | |
| // Pipe management | |
| function spawnPipe() { | |
| const pipeW = Math.max(60, Math.min(120, W*0.08)); | |
| const minGap = clamp(H * 0.18, 120, 240); | |
| const maxGap = clamp(H * 0.28, 160, 340); | |
| const baseGap = clamp(maxGap - score*2 - wanted*0.8, minGap, maxGap); | |
| const gapH = baseGap; | |
| const margin = H * 0.14; | |
| const gapY = rand(margin + gapH/2, H - (H*0.18) - gapH/2); // avoid floor | |
| const coinChance = 0.82; | |
| const pipe = { | |
| x: W + pipeW, | |
| w: pipeW, | |
| gapY, | |
| gapH, | |
| passed: false, | |
| coin: Math.random() < coinChance ? { y: gapY, taken: false, sway: rand(0, 6.28) } : null, | |
| }; | |
| pipes.push(pipe); | |
| } | |
| function resetGame() { | |
| score = 0; | |
| passedCount = 0; | |
| wanted = 0; | |
| pipes.length = 0; | |
| particles.length = 0; | |
| timeSinceSpawn = 0; | |
| scrollSpeed = Math.max(W, 800) * 0.22; // scale with width | |
| spawnInterval = 1.25; | |
| // player placement | |
| player.w = Math.max(46, Math.min(64, W * 0.06)); | |
| player.h = player.w * 0.54; | |
| player.x = W * 0.28; | |
| player.y = H * 0.46; | |
| player.vy = 0; | |
| player.rot = 0; | |
| } | |
| function startGame() { | |
| hide(menuOverlay); | |
| hide(gameoverOverlay); | |
| state = 'play'; | |
| resetGame(); | |
| ping('start'); | |
| } | |
| function toMenu() { | |
| state = 'menu'; | |
| show(menuOverlay); | |
| hide(gameoverOverlay); | |
| } | |
| function gameOver() { | |
| state = 'dead'; | |
| shakeTimer = 0.45; | |
| ping('crash'); | |
| bestScore = Math.max(bestScore, score); | |
| localStorage.setItem(LS_KEY, bestScore); | |
| bestChip.textContent = 'Best: ' + bestScore; | |
| finalStats.innerHTML = `Score: <b>${score}</b> • Best: <b>${bestScore}</b> • Wanted: <b>${Math.round(wanted)}%</b>`; | |
| setTimeout(()=>show(gameoverOverlay), 200); | |
| } | |
| // Drawing helpers | |
| function roundRect(x, y, w, h, r) { | |
| const rr = Math.min(r, w/2, h/2); | |
| ctx.beginPath(); | |
| ctx.moveTo(x+rr, y); | |
| ctx.arcTo(x+w, y, x+w, y+h, rr); | |
| ctx.arcTo(x+w, y+h, x, y+h, rr); | |
| ctx.arcTo(x, y+h, x, y, rr); | |
| ctx.arcTo(x, y, x+w, y, rr); | |
| ctx.closePath(); | |
| } | |
| function drawCar(p) { | |
| ctx.save(); | |
| ctx.translate(p.x, p.y); | |
| ctx.rotate(p.rot); | |
| const w = p.w, h = p.h; | |
| // Body | |
| ctx.fillStyle = '#1e2b48'; | |
| roundRect(-w/2, -h/2, w, h, 6); | |
| ctx.fill(); | |
| // stripes | |
| ctx.fillStyle = '#3ea7ff'; | |
| ctx.fillRect(-w*0.35, -h*0.5, w*0.7, h*0.18); | |
| ctx.fillStyle = '#6bd6ff'; | |
| ctx.fillRect(-w*0.25, -h*0.32, w*0.5, h*0.12); | |
| // windows | |
| ctx.fillStyle = '#a6d8ff'; | |
| roundRect(-w*0.18, -h*0.22, w*0.36, h*0.22, 4); ctx.fill(); | |
| // Wheels | |
| ctx.fillStyle = '#0a0f16'; | |
| roundRect(-w*0.43, h*0.2, w*0.22, h*0.24, 5); ctx.fill(); | |
| roundRect(w*0.21, h*0.2, w*0.22, h*0.24, 5); ctx.fill(); | |
| // Headlight glow | |
| const g = ctx.createLinearGradient(0, 0, w*0.9, 0); | |
| g.addColorStop(0, 'rgba(255,255,200,0.7)'); | |
| g.addColorStop(1, 'rgba(255,255,200,0)'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); | |
| ctx.moveTo(w*0.5, -h*0.18); | |
| ctx.lineTo(w*0.5, h*0.18); | |
| ctx.lineTo(w*1.2, h*0.08); | |
| ctx.lineTo(w*1.2, -h*0.08); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Tail spark | |
| ctx.globalAlpha = 0.7; | |
| ctx.fillStyle = '#ff814a'; | |
| ctx.beginPath(); | |
| ctx.ellipse(-w*0.55, 0, w*0.12 + Math.abs(player.vy)*0.02, h*0.12, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| ctx.restore(); | |
| } | |
| function drawPipe(pipe) { | |
| const x = pipe.x, w = pipe.w; | |
| const topH = pipe.gapY - pipe.gapH/2; | |
| const botY = pipe.gapY + pipe.gapH/2; | |
| const botH = Math.max(0, (H - H*0.18) - botY); | |
| ctx.fillStyle = '#0f1827'; | |
| // Top | |
| roundRect(x, 0, w, topH, 8); ctx.fill(); | |
| // Bottom | |
| roundRect(x, botY, w, botH, 8); ctx.fill(); | |
| // Neon edges | |
| ctx.globalAlpha = 0.22 + wanted*0.005; | |
| ctx.fillStyle = '#3c8bff'; | |
| ctx.fillRect(x-2, 0, 2, topH); | |
| ctx.fillRect(x+w, 0, 2, topH); | |
| ctx.fillRect(x-2, botY, 2, botH); | |
| ctx.fillRect(x+w, botY, 2, botH); | |
| ctx.globalAlpha = 1; | |
| // Coin | |
| if (pipe.coin && !pipe.coin.taken) { | |
| const cy = pipe.coin.y + Math.sin(pipe.coin.sway)*10; | |
| pipe.coin.sway += 2.2 * dt; | |
| const r = Math.max(10, Math.min(18, H*0.02)); | |
| const cx = x + w/2; | |
| const grd = ctx.createRadialGradient(cx, cy, r*0.2, cx, cy, r); | |
| grd.addColorStop(0, '#fff6b0'); | |
| grd.addColorStop(1, '#f3c021'); | |
| ctx.fillStyle = grd; | |
| ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill(); | |
| ctx.lineWidth = 2*DPR; ctx.strokeStyle = 'rgba(0,0,0,0.35)'; ctx.stroke(); | |
| ctx.fillStyle = '#1a1a1a'; | |
| ctx.font = `${Math.round(r*1.2)}px ui-sans-serif`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText('$', cx, cy+1*DPR); | |
| } | |
| } | |
| function drawRoad() { | |
| const roadH = H * 0.18; | |
| // road base | |
| const g = ctx.createLinearGradient(0, H-roadH, 0, H); | |
| g.addColorStop(0, '#0b111b'); | |
| g.addColorStop(1, '#05080e'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0, H-roadH, W, roadH); | |
| // lane dashes | |
| const dashW = 60 * DPR, dashH = 6 * DPR; | |
| const laneY = H - roadH*0.55; | |
| ctx.fillStyle = 'rgba(255,255,255,0.22)'; | |
| let offset = (sirenPhase * 140) % (dashW * 3); | |
| for (let x = -dashW*2 + offset; x < W + dashW; x += dashW*3) { | |
| ctx.fillRect(x, laneY, dashW, dashH); | |
| } | |
| // curb glow | |
| ctx.fillStyle = 'rgba(60,140,255,0.08)'; | |
| ctx.fillRect(0, H-roadH, W, 2*DPR); | |
| } | |
| // Particles | |
| function spawnBurst(x, y, color, n = 12) { | |
| for (let i=0;i<n;i++) { | |
| particles.push({ | |
| x, y, | |
| vx: rand(-180, 180), vy: rand(-260, 40), | |
| life: rand(.35, .7), | |
| t: 0, | |
| color, | |
| size: rand(2,5)*DPR | |
| }); | |
| } | |
| } | |
| function updateParticles(dt) { | |
| for (let i=particles.length-1;i>=0;i--) { | |
| const p = particles[i]; | |
| p.t += dt; | |
| if (p.t >= p.life) { particles.splice(i,1); continue; } | |
| p.vx *= 0.98; | |
| p.vy += 1200*dt; | |
| p.x += p.vx*dt; | |
| p.y += p.vy*dt; | |
| } | |
| } | |
| function drawParticles() { | |
| for (const p of particles) { | |
| const a = 1 - (p.t / p.life); | |
| ctx.globalAlpha = a; | |
| ctx.fillStyle = p.color; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); | |
| ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| // Collision helpers | |
| function rectsIntersect(ax, ay, aw, ah, bx, by, bw, bh) { | |
| return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; | |
| } | |
| function circleRectCollision(cx, cy, r, rx, ry, rw, rh) { | |
| // clamped point | |
| const x = clamp(cx, rx, rx + rw); | |
| const y = clamp(cy, ry, ry + rh); | |
| const dx = cx - x; | |
| const dy = cy - y; | |
| return (dx*dx + dy*dy) <= r*r; | |
| } | |
| // Audio helpers | |
| function ensureAudio() { | |
| if (!actx) { | |
| actx = new AudioCtx(); | |
| } | |
| } | |
| function tone(freq = 600, dur = 0.08, type='sine', gain=0.05) { | |
| if (!actx) return; | |
| const o = actx.createOscillator(); | |
| const g = actx.createGain(); | |
| o.type = type; | |
| o.frequency.value = freq; | |
| g.gain.value = gain; | |
| o.connect(g).connect(actx.destination); | |
| o.start(); | |
| o.stop(actx.currentTime + dur); | |
| } | |
| function ping(kind) { | |
| if (!actx) return; | |
| if (kind === 'cash') { | |
| tone(740, .06, 'triangle', 0.08); | |
| tone(1180, .06, 'triangle', 0.05); | |
| } else if (kind === 'pass') { | |
| tone(540, .05, 'sine', 0.05); | |
| } else if (kind === 'crash') { | |
| tone(100, .12, 'square', 0.12); | |
| setTimeout(()=>tone(80, .18, 'sawtooth', 0.08), 60); | |
| } else if (kind === 'start') { | |
| tone(420, .06, 'triangle', 0.05); | |
| setTimeout(()=>tone(520, .06, 'triangle', 0.05), 60); | |
| } | |
| } | |
| // Input handling | |
| function flap() { | |
| if (state !== 'play') return; | |
| ensureAudio(); | |
| player.vy = jumpVel; | |
| spawnBurst(player.x - player.w*0.55, player.y, 'rgba(255,145,90,0.7)', 4); | |
| } | |
| window.addEventListener('pointerdown', (e) => { | |
| if (state === 'menu') { ensureAudio(); startGame(); return; } | |
| if (state === 'dead') return; | |
| flapQueued = true; | |
| }); | |
| window.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| if (state === 'menu') { ensureAudio(); startGame(); return; } | |
| if (state === 'dead') return; | |
| flapQueued = true; | |
| } else if (e.key.toLowerCase() === 'p') { | |
| if (state === 'play') { state = 'pause'; } | |
| else if (state === 'pause') { state = 'play'; } | |
| } else if (e.key.toLowerCase() === 'r') { | |
| if (state === 'dead') { startGame(); } | |
| } | |
| }); | |
| // Buttons | |
| startBtn.addEventListener('click', () => { ensureAudio(); startGame(); }); | |
| retryBtn.addEventListener('click', () => { ensureAudio(); startGame(); }); | |
| menuBtn.addEventListener('click', () => toMenu()); | |
| // Overlay helpers | |
| function show(el) { el.style.display = ''; } | |
| function hide(el) { el.style.display = 'none'; } | |
| // Camera shake | |
| let shakeTimer = 0; | |
| function applyShake() { | |
| if (shakeTimer > 0) { | |
| const s = shakeTimer; | |
| const dx = (Math.random()*2-1)*8*DPR*s; | |
| const dy = (Math.random()*2-1)*8*DPR*s; | |
| ctx.setTransform(1,0,0,1, dx, dy); | |
| shakeTimer = Math.max(0, shakeTimer - dt*2.2); | |
| } else { | |
| ctx.setTransform(1,0,0,1, 0, 0); | |
| } | |
| } | |
| // Main loop | |
| let dt = 0; | |
| function loop(t) { | |
| if (!lastT) lastT = t; | |
| dt = Math.min(0.032, (t - lastT) / 1000); // cap dt | |
| lastT = t; | |
| if (running) update(dt); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| requestAnimationFrame(loop); | |
| // Update | |
| function update(dt) { | |
| sirenPhase += dt; | |
| if (state === 'play') { | |
| if (flapQueued) { flap(); flapQueued = false; } | |
| // Difficulty ramp | |
| wanted += dt * 5; // rises over time | |
| wanted = clamp(wanted, 0, 100); | |
| // base speed + ramp with wanted | |
| const speedBoost = (wanted > 50) ? (wanted-50) * 3.5 : 0; | |
| scrollSpeed = Math.max(W*0.18, W*0.22 + speedBoost); | |
| // spawn rate tightens with wanted | |
| const targetSpawn = clamp(1.25 - wanted*0.004, 0.7, 1.25); | |
| spawnInterval += (targetSpawn - spawnInterval) * 0.03; | |
| timeSinceSpawn += dt; | |
| if (timeSinceSpawn >= spawnInterval) { | |
| timeSinceSpawn = 0; | |
| spawnPipe(); | |
| } | |
| // Update pipes | |
| for (let i=pipes.length-1;i>=0;i--) { | |
| const p = pipes[i]; | |
| p.x -= scrollSpeed * dt; | |
| // Passing score | |
| if (!p.passed && p.x + p.w < player.x - player.w/2) { | |
| p.passed = true; | |
| score++; | |
| ping('pass'); | |
| } | |
| // Coin collection | |
| if (p.coin && !p.coin.taken) { | |
| const cx = p.x + p.w/2; | |
| const cy = p.coin.y + Math.sin(p.coin.sway)*10; | |
| const r = Math.max(10, Math.min(18, H*0.02)); | |
| if (circleRectCollision(cx, cy, r, player.x - player.w/2, player.y - player.h/2, player.w, player.h)) { | |
| p.coin.taken = true; | |
| score += 4; | |
| wanted = Math.max(0, wanted - 14); | |
| spawnBurst(cx, cy, 'rgba(255,230,120,0.9)', 14); | |
| ping('cash'); | |
| } | |
| } | |
| // Cleanup | |
| if (p.x + p.w < -80) pipes.splice(i,1); | |
| } | |
| // Player physics | |
| player.vy += gravity * dt; | |
| player.y += player.vy * dt; | |
| player.rot = clamp((player.vy / 1000), -0.45, 1.0); | |
| // Collisions with pipes | |
| const px = player.x - player.w/2; | |
| const py = player.y - player.h/2; | |
| for (const p of pipes) { | |
| const topH = p.gapY - p.gapH/2; | |
| const botY = p.gapY + p.gapH/2; | |
| const botH = Math.max(0, (H - H*0.18) - botY); | |
| if (rectsIntersect(px, py, player.w, player.h, p.x, 0, p.w, topH) || | |
| rectsIntersect(px, py, player.w, player.h, p.x, botY, p.w, botH)) { | |
| gameOver(); | |
| break; | |
| } | |
| } | |
| // Floor/Ceiling | |
| const floorY = H - H*0.18; | |
| if (player.y + player.h/2 >= floorY) { | |
| player.y = floorY - player.h/2; | |
| gameOver(); | |
| } else if (player.y - player.h/2 <= 0) { | |
| player.y = player.h/2; | |
| player.vy = 0; | |
| gameOver(); | |
| } | |
| // Particles | |
| updateParticles(dt); | |
| } else { | |
| // Not playing: gentle idle WANTED decay | |
| wanted = Math.max(0, wanted - dt*10); | |
| } | |
| // Update UI | |
| scoreChip.textContent = 'Score: ' + score; | |
| wantedChip.textContent = 'Wanted: ' + Math.round(wanted) + '%'; | |
| } | |
| // Draw | |
| function draw() { | |
| // Clear | |
| ctx.setTransform(1,0,0,1, 0, 0); | |
| ctx.fillStyle = '#0b0f14'; | |
| ctx.fillRect(0,0,W,H); | |
| // Sky gradient | |
| const sky = ctx.createLinearGradient(0, 0, 0, H); | |
| sky.addColorStop(0, '#0d1421'); | |
| sky.addColorStop(1, '#0b0f14'); | |
| ctx.fillStyle = sky; | |
| ctx.fillRect(0,0,W,H); | |
| // Stars | |
| ctx.globalAlpha = 0.25; | |
| ctx.fillStyle = '#b7d0ff'; | |
| const starCount = Math.floor((W*H) / (22000)); | |
| for (let i=0;i<starCount;i++) { | |
| const x = (i*1315423911 % W); | |
| const y = (i*2654435761 % Math.floor(H*0.6)); | |
| ctx.fillRect((x + Math.floor(sirenPhase*50*i)) % W, y, 1*DPR, 1*DPR); | |
| } | |
| ctx.globalAlpha = 1; | |
| // Parallax skyline layers | |
| drawSkyline(skyline1, 0.08); | |
| drawSkyline(skyline2, 0.16); | |
| // Pipes and coins | |
| for (const p of pipes) drawPipe(p); | |
| // Particles behind player | |
| drawParticles(); | |
| // Player | |
| applyShake(); | |
| drawCar(player); | |
| ctx.setTransform(1,0,0,1, 0, 0); | |
| // Road | |
| drawRoad(); | |
| // Siren overlay at high wanted | |
| if (wanted > 60) { | |
| const a = clamp((wanted-60)/40, 0, 1) * 0.35; | |
| const blink = (Math.sin(sirenPhase*6) + 1) / 2; | |
| ctx.globalAlpha = a * (0.9*blink + 0.1); | |
| // left red | |
| ctx.fillStyle = '#ff2c45'; | |
| ctx.fillRect(0, 0, W*0.06, H); | |
| // right blue | |
| ctx.fillStyle = '#3a7bff'; | |
| ctx.fillRect(W - W*0.06, 0, W*0.06, H); | |
| ctx.globalAlpha = 1; | |
| } | |
| // State overlays text hints (subtle) | |
| if (state === 'pause') { | |
| ctx.fillStyle = 'rgba(10,16,28,.6)'; | |
| ctx.fillRect(0,0,W,H); | |
| ctx.fillStyle = '#dbe6ff'; | |
| ctx.font = `${Math.round(32*DPR)}px ui-sans-serif`; | |
| ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| ctx.fillText('Paused — press P or tap to resume', W/2, H/2); | |
| } | |
| } | |
| // Kick things off | |
| resize(); | |
| toMenu(); | |
| // ———————————————————————————————— | |
| // Optional: start on first user gesture for iOS audio | |
| // ———————————————————————————————— | |
| // Prevent context menu on long-press | |
| window.addEventListener('contextmenu', (e)=>e.preventDefault(), {passive:false}); | |
| // Note: Using global dt variable inside skyline draw | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment