Add Flappy Bird game - HTML5 Canvas implementation
This commit is contained in:
commit
e1c8e971fd
2 changed files with 316 additions and 0 deletions
279
game.js
Normal file
279
game.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
// --- Constants ---
|
||||
const GRAVITY = 0.45;
|
||||
const FLAP_FORCE = -8.5;
|
||||
const PIPE_SPEED = 2.8;
|
||||
const PIPE_WIDTH = 60;
|
||||
const PIPE_GAP = 160;
|
||||
const PIPE_INTERVAL = 90; // frames between pipes
|
||||
const BIRD_X = 90;
|
||||
const BIRD_R = 18; // collision radius (slightly smaller than visual)
|
||||
|
||||
// --- Palette ---
|
||||
const SKY_TOP = '#4ec0e4';
|
||||
const SKY_BOT = '#87ceeb';
|
||||
const GROUND_COL = '#ded895';
|
||||
const PIPE_COL = '#4caf50';
|
||||
const PIPE_DARK = '#388e3c';
|
||||
const BIRD_BODY = '#ffd700';
|
||||
const BIRD_WING = '#ffb300';
|
||||
const BIRD_EYE = '#fff';
|
||||
const BIRD_PUPIL = '#222';
|
||||
const BIRD_BEAK = '#ff8c00';
|
||||
|
||||
// --- State ---
|
||||
let bird, pipes, score, frame, state, bestScore;
|
||||
// states: 'idle' | 'playing' | 'dead'
|
||||
|
||||
function init() {
|
||||
bird = { y: H / 2, vy: 0, angle: 0, wingPhase: 0 };
|
||||
pipes = [];
|
||||
score = 0;
|
||||
frame = 0;
|
||||
state = 'idle';
|
||||
bestScore = bestScore || 0;
|
||||
}
|
||||
|
||||
// --- Input ---
|
||||
function flap() {
|
||||
if (state === 'idle') { state = 'playing'; }
|
||||
if (state === 'playing') {
|
||||
bird.vy = FLAP_FORCE;
|
||||
bird.wingPhase = 0;
|
||||
}
|
||||
if (state === 'dead') { init(); }
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); flap(); }});
|
||||
canvas.addEventListener('click', flap);
|
||||
canvas.addEventListener('touchstart', e => { e.preventDefault(); flap(); }, { passive: false });
|
||||
|
||||
// --- Pipe helpers ---
|
||||
function spawnPipe() {
|
||||
const minTop = 80;
|
||||
const maxTop = H - 120 - PIPE_GAP;
|
||||
const topH = Math.floor(Math.random() * (maxTop - minTop + 1)) + minTop;
|
||||
pipes.push({ x: W + 10, topH, passed: false });
|
||||
}
|
||||
|
||||
// --- Collision ---
|
||||
function circleRect(cx, cy, r, rx, ry, rw, rh) {
|
||||
const nearX = Math.max(rx, Math.min(cx, rx + rw));
|
||||
const nearY = Math.max(ry, Math.min(cy, ry + rh));
|
||||
const dx = cx - nearX, dy = cy - nearY;
|
||||
return dx * dx + dy * dy < r * r;
|
||||
}
|
||||
|
||||
function checkCollision() {
|
||||
const GROUND_Y = H - 80;
|
||||
if (bird.y + BIRD_R >= GROUND_Y) return true;
|
||||
if (bird.y - BIRD_R <= 0) return true;
|
||||
for (const p of pipes) {
|
||||
const cap = 6; // pipe cap extra width
|
||||
if (
|
||||
circleRect(BIRD_X, bird.y, BIRD_R, p.x - cap, 0, PIPE_WIDTH + cap * 2, p.topH) ||
|
||||
circleRect(BIRD_X, bird.y, BIRD_R, p.x - cap, p.topH + PIPE_GAP, PIPE_WIDTH + cap * 2, H)
|
||||
) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Draw helpers ---
|
||||
function drawSky() {
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
grad.addColorStop(0, SKY_TOP);
|
||||
grad.addColorStop(1, SKY_BOT);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
|
||||
function drawGround() {
|
||||
const gy = H - 80;
|
||||
ctx.fillStyle = GROUND_COL;
|
||||
ctx.fillRect(0, gy, W, 80);
|
||||
ctx.fillStyle = '#8bc34a';
|
||||
ctx.fillRect(0, gy, W, 12);
|
||||
}
|
||||
|
||||
function drawPipe(p) {
|
||||
const capH = 24, capW = PIPE_WIDTH + 12;
|
||||
const capX = p.x - 6;
|
||||
|
||||
// bottom pipe
|
||||
const botY = p.topH + PIPE_GAP;
|
||||
ctx.fillStyle = PIPE_COL;
|
||||
ctx.fillRect(p.x, botY, PIPE_WIDTH, H - botY);
|
||||
ctx.fillStyle = PIPE_DARK;
|
||||
ctx.fillRect(p.x, botY, 6, H - botY);
|
||||
// bottom cap
|
||||
ctx.fillStyle = PIPE_COL;
|
||||
ctx.fillRect(capX, botY, capW, capH);
|
||||
ctx.fillStyle = PIPE_DARK;
|
||||
ctx.fillRect(capX, botY, 6, capH);
|
||||
|
||||
// top pipe
|
||||
ctx.fillStyle = PIPE_COL;
|
||||
ctx.fillRect(p.x, 0, PIPE_WIDTH, p.topH);
|
||||
ctx.fillStyle = PIPE_DARK;
|
||||
ctx.fillRect(p.x, 0, 6, p.topH);
|
||||
// top cap
|
||||
ctx.fillStyle = PIPE_COL;
|
||||
ctx.fillRect(capX, p.topH - capH, capW, capH);
|
||||
ctx.fillStyle = PIPE_DARK;
|
||||
ctx.fillRect(capX, p.topH - capH, 6, capH);
|
||||
}
|
||||
|
||||
function drawBird() {
|
||||
ctx.save();
|
||||
ctx.translate(BIRD_X, bird.y);
|
||||
|
||||
const angle = Math.min(Math.max(bird.vy * 3, -30), 80) * Math.PI / 180;
|
||||
ctx.rotate(angle);
|
||||
|
||||
// wing
|
||||
const wingY = 4 + Math.sin(bird.wingPhase) * 8;
|
||||
ctx.fillStyle = BIRD_WING;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(-4, wingY, 10, 6, -0.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// body
|
||||
ctx.fillStyle = BIRD_BODY;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, 22, 18, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// white belly
|
||||
ctx.fillStyle = '#fff8dc';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(4, 4, 12, 9, 0.3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// eye
|
||||
ctx.fillStyle = BIRD_EYE;
|
||||
ctx.beginPath();
|
||||
ctx.arc(9, -5, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = BIRD_PUPIL;
|
||||
ctx.beginPath();
|
||||
ctx.arc(11, -5, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// shine
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(12, -7, 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// beak
|
||||
ctx.fillStyle = BIRD_BEAK;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(18, -2);
|
||||
ctx.lineTo(28, 2);
|
||||
ctx.lineTo(18, 6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawScore() {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.font = 'bold 42px Segoe UI, Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.strokeText(score, W / 2, 70);
|
||||
ctx.fillText(score, W / 2, 70);
|
||||
}
|
||||
|
||||
function drawOverlay(title, sub) {
|
||||
// panel
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(W / 2 - 140, H / 2 - 120, 280, 240, 16);
|
||||
ctx.fill();
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
ctx.fillStyle = '#ffd700';
|
||||
ctx.font = 'bold 36px Segoe UI, Arial';
|
||||
ctx.fillText(title, W / 2, H / 2 - 68);
|
||||
|
||||
if (state === 'dead') {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '20px Segoe UI, Arial';
|
||||
ctx.fillText(`Score: ${score}`, W / 2, H / 2 - 20);
|
||||
ctx.fillText(`Best: ${bestScore}`, W / 2, H / 2 + 14);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#87ceeb';
|
||||
ctx.font = '18px Segoe UI, Arial';
|
||||
ctx.fillText(sub, W / 2, H / 2 + 60);
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(W / 2 - 100, H / 2 + 74, 200, 42, 10);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 16px Segoe UI, Arial';
|
||||
ctx.fillText('Space / Click / Tap', W / 2, H / 2 + 100);
|
||||
}
|
||||
|
||||
// --- Main loop ---
|
||||
function update() {
|
||||
if (state === 'playing') {
|
||||
bird.vy += GRAVITY;
|
||||
bird.y += bird.vy;
|
||||
bird.wingPhase += 0.25;
|
||||
|
||||
// Spawn pipes
|
||||
if (frame % PIPE_INTERVAL === 0) spawnPipe();
|
||||
|
||||
// Move pipes & score
|
||||
for (const p of pipes) {
|
||||
p.x -= PIPE_SPEED;
|
||||
if (!p.passed && p.x + PIPE_WIDTH < BIRD_X) {
|
||||
p.passed = true;
|
||||
score++;
|
||||
if (score > bestScore) bestScore = score;
|
||||
}
|
||||
}
|
||||
// Remove off-screen pipes
|
||||
pipes = pipes.filter(p => p.x + PIPE_WIDTH + 20 > 0);
|
||||
|
||||
if (checkCollision()) { state = 'dead'; }
|
||||
|
||||
frame++;
|
||||
}
|
||||
|
||||
if (state === 'idle') {
|
||||
bird.y = H / 2 + Math.sin(frame * 0.05) * 10;
|
||||
frame++;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
drawSky();
|
||||
pipes.forEach(drawPipe);
|
||||
drawGround();
|
||||
drawBird();
|
||||
|
||||
if (state === 'playing' || state === 'dead') drawScore();
|
||||
|
||||
if (state === 'idle') drawOverlay('Flappy Bird', 'Get ready!');
|
||||
if (state === 'dead') drawOverlay('Game Over', 'Try again?');
|
||||
}
|
||||
|
||||
function loop() {
|
||||
update();
|
||||
render();
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
init();
|
||||
loop();
|
||||
37
index.html
Normal file
37
index.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flappy Bird</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #fff;
|
||||
}
|
||||
canvas {
|
||||
border: 3px solid #16213e;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 40px rgba(0,200,255,0.2);
|
||||
display: block;
|
||||
}
|
||||
#info {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas" width="400" height="600"></canvas>
|
||||
<div id="info">Space / Click / Tap to flap</div>
|
||||
<script src="game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue