-
Notifications
You must be signed in to change notification settings - Fork 51
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: flappy goose easter egg (#479)
- Loading branch information
Showing
2 changed files
with
329 additions
and
3 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,320 @@ | ||
import React, { useEffect, useRef, useState } from 'react'; | ||
|
||
declare var requestAnimationFrame: (callback: FrameRequestCallback) => number; | ||
declare class HTMLCanvasElement {} | ||
declare class HTMLImageElement {} | ||
declare class DOMHighResTimeStamp {} | ||
declare class Image {} | ||
declare type FrameRequestCallback = (time: DOMHighResTimeStamp) => void; | ||
import svg1 from '../images/loading-goose/1.svg'; | ||
import svg7 from '../images/loading-goose/7.svg'; | ||
|
||
interface Obstacle { | ||
x: number; | ||
gapY: number; | ||
passed: boolean; | ||
} | ||
|
||
interface FlappyGooseProps { | ||
onClose: () => void; | ||
} | ||
|
||
const FlappyGoose: React.FC<FlappyGooseProps> = ({ onClose }) => { | ||
const canvasRef = useRef<HTMLCanvasElement>(null); | ||
const [gameOver, setGameOver] = useState(false); | ||
const [displayScore, setDisplayScore] = useState(0); | ||
const gooseImages = useRef<HTMLImageElement[]>([]); | ||
const framesLoaded = useRef(0); | ||
const [imagesReady, setImagesReady] = useState(false); | ||
|
||
// Game state | ||
const gameState = useRef({ | ||
gooseY: 200, | ||
velocity: 0, | ||
obstacles: [] as Obstacle[], | ||
gameLoop: 0, | ||
running: false, | ||
score: 0, | ||
isFlapping: false, | ||
flapEndTime: 0 | ||
}); | ||
|
||
// Game settings | ||
const CANVAS_WIDTH = 600; | ||
const CANVAS_HEIGHT = 400; | ||
const GRAVITY = 0.35; | ||
const FLAP_FORCE = -7; | ||
const OBSTACLE_SPEED = 2.5; | ||
const OBSTACLE_GAP = 180; | ||
const GOOSE_SIZE = 35; | ||
const GOOSE_X = 50; | ||
const OBSTACLE_WIDTH = 40; | ||
const FLAP_DURATION = 150; | ||
|
||
const safeRequestAnimationFrame = (callback: FrameRequestCallback) => { | ||
if (typeof window !== 'undefined' && typeof requestAnimationFrame !== 'undefined') { | ||
requestAnimationFrame(callback); | ||
} | ||
}; | ||
|
||
// Load goose images | ||
useEffect(() => { | ||
const frames = [svg1, svg7]; | ||
frames.forEach((src, index) => { | ||
const img = new Image(); | ||
img.src = src; | ||
img.onload = () => { | ||
framesLoaded.current += 1; | ||
if (framesLoaded.current === frames.length) { | ||
setImagesReady(true); | ||
} | ||
}; | ||
gooseImages.current[index] = img; | ||
}); | ||
}, []); | ||
|
||
const startGame = () => { | ||
if (gameState.current.running || !imagesReady || typeof window === 'undefined') return; | ||
|
||
gameState.current = { | ||
gooseY: CANVAS_HEIGHT / 3, | ||
velocity: 0, | ||
obstacles: [], | ||
gameLoop: 0, | ||
running: true, | ||
score: 0, | ||
isFlapping: false, | ||
flapEndTime: 0 | ||
}; | ||
setGameOver(false); | ||
setDisplayScore(0); | ||
safeRequestAnimationFrame(gameLoop); | ||
}; | ||
|
||
const flap = () => { | ||
if (gameOver) { | ||
startGame(); | ||
return; | ||
} | ||
gameState.current.velocity = FLAP_FORCE; | ||
gameState.current.isFlapping = true; | ||
gameState.current.flapEndTime = Date.now() + FLAP_DURATION; | ||
}; | ||
|
||
const gameLoop = () => { | ||
if (!gameState.current.running || !imagesReady) return; | ||
const canvas = canvasRef.current; | ||
if (!canvas) return; | ||
|
||
const ctx = canvas.getContext('2d'); | ||
if (!ctx) return; | ||
|
||
// Check if flap animation should end | ||
if (gameState.current.isFlapping && Date.now() >= gameState.current.flapEndTime) { | ||
gameState.current.isFlapping = false; | ||
} | ||
|
||
// Update goose position | ||
gameState.current.velocity += GRAVITY; | ||
gameState.current.gooseY += gameState.current.velocity; | ||
|
||
// Generate obstacles | ||
if (gameState.current.gameLoop % 120 === 0) { | ||
gameState.current.obstacles.push({ | ||
x: CANVAS_WIDTH, | ||
gapY: Math.random() * (CANVAS_HEIGHT - OBSTACLE_GAP - 100) + 50, | ||
passed: false | ||
}); | ||
} | ||
|
||
// Update obstacles and check for score | ||
gameState.current.obstacles = gameState.current.obstacles.filter(obstacle => { | ||
obstacle.x -= OBSTACLE_SPEED; | ||
|
||
// Check for score when the goose passes the middle of the obstacle | ||
const obstacleMiddle = obstacle.x + OBSTACLE_WIDTH / 2; | ||
const gooseMiddle = GOOSE_X + GOOSE_SIZE / 2; | ||
|
||
if (!obstacle.passed && obstacleMiddle < gooseMiddle) { | ||
obstacle.passed = true; | ||
gameState.current.score += 1; | ||
setDisplayScore(gameState.current.score); | ||
} | ||
|
||
return obstacle.x > -OBSTACLE_WIDTH; | ||
}); | ||
|
||
// Check collisions | ||
const gooseBox = { | ||
x: GOOSE_X, | ||
y: gameState.current.gooseY, | ||
width: GOOSE_SIZE, | ||
height: GOOSE_SIZE | ||
}; | ||
|
||
// Collision with ground or ceiling | ||
if (gameState.current.gooseY < 0 || gameState.current.gooseY > CANVAS_HEIGHT - GOOSE_SIZE) { | ||
handleGameOver(); | ||
return; | ||
} | ||
|
||
// Collision with obstacles | ||
for (const obstacle of gameState.current.obstacles) { | ||
if ( | ||
gooseBox.x < obstacle.x + OBSTACLE_WIDTH && | ||
gooseBox.x + gooseBox.width > obstacle.x | ||
) { | ||
if ( | ||
gooseBox.y < obstacle.gapY - OBSTACLE_GAP/2 || | ||
gooseBox.y + gooseBox.height > obstacle.gapY + OBSTACLE_GAP/2 | ||
) { | ||
handleGameOver(); | ||
return; | ||
} | ||
} | ||
} | ||
|
||
// Draw game | ||
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); | ||
|
||
// Draw rotated goose | ||
ctx.save(); | ||
ctx.translate(GOOSE_X + GOOSE_SIZE/2, gameState.current.gooseY + GOOSE_SIZE/2); | ||
const rotation = Math.min(Math.max(gameState.current.velocity * 0.05, -0.5), 0.5); | ||
ctx.rotate(rotation); | ||
ctx.drawImage( | ||
gooseImages.current[gameState.current.isFlapping ? 1 : 0], | ||
-GOOSE_SIZE/2, | ||
-GOOSE_SIZE/2, | ||
GOOSE_SIZE, | ||
GOOSE_SIZE | ||
); | ||
ctx.restore(); | ||
|
||
// Draw obstacles | ||
ctx.fillStyle = '#4CAF50'; | ||
gameState.current.obstacles.forEach(obstacle => { | ||
// Top obstacle | ||
ctx.fillRect( | ||
obstacle.x, | ||
0, | ||
OBSTACLE_WIDTH, | ||
obstacle.gapY - OBSTACLE_GAP/2 | ||
); | ||
// Bottom obstacle | ||
ctx.fillRect( | ||
obstacle.x, | ||
obstacle.gapY + OBSTACLE_GAP/2, | ||
OBSTACLE_WIDTH, | ||
CANVAS_HEIGHT - (obstacle.gapY + OBSTACLE_GAP/2) | ||
); | ||
}); | ||
|
||
// Draw score | ||
ctx.fillStyle = '#000'; | ||
ctx.font = '24px Arial'; | ||
ctx.fillText(`Score: ${gameState.current.score}`, 10, 30); | ||
|
||
gameState.current.gameLoop++; | ||
safeRequestAnimationFrame(gameLoop); | ||
}; | ||
|
||
const handleGameOver = () => { | ||
gameState.current.running = false; | ||
setGameOver(true); | ||
}; | ||
|
||
useEffect(() => { | ||
const canvas = canvasRef.current; | ||
if (!canvas) return; | ||
|
||
canvas.width = CANVAS_WIDTH; | ||
canvas.height = CANVAS_HEIGHT; | ||
|
||
const handleKeyPress = (e: KeyboardEvent) => { | ||
if (e.code === 'Space') { | ||
e.preventDefault(); | ||
flap(); | ||
} | ||
}; | ||
|
||
window.addEventListener('keydown', handleKeyPress); | ||
|
||
if (imagesReady) { | ||
startGame(); | ||
} | ||
|
||
return () => { | ||
window.removeEventListener('keydown', handleKeyPress); | ||
gameState.current.running = false; | ||
}; | ||
}, [imagesReady]); | ||
|
||
return ( | ||
<div | ||
style={{ | ||
position: 'fixed', | ||
top: 0, | ||
left: 0, | ||
width: '100%', | ||
height: '100%', | ||
backgroundColor: 'rgba(0, 0, 0, 0.8)', | ||
display: 'flex', | ||
flexDirection: 'column', | ||
alignItems: 'center', | ||
justifyContent: 'center', | ||
zIndex: 1000 | ||
}} | ||
onClick={flap} | ||
> | ||
<canvas | ||
ref={canvasRef} | ||
style={{ | ||
border: '2px solid #333', | ||
borderRadius: '8px', | ||
backgroundColor: '#87CEEB', | ||
maxWidth: '100%', | ||
maxHeight: '100vh' | ||
}} | ||
/> | ||
{!imagesReady && ( | ||
<div style={{ color: 'white', fontSize: '24px' }}>Loading...</div> | ||
)} | ||
{gameOver && ( | ||
<div | ||
style={{ | ||
position: 'absolute', | ||
color: 'white', | ||
fontSize: '24px', | ||
textAlign: 'center' | ||
}} | ||
> | ||
<p>Game Over!</p> | ||
<p>Score: {displayScore}</p> | ||
<p>Click or press space to play again</p> | ||
</div> | ||
)} | ||
<button | ||
onClick={(e) => { | ||
e.stopPropagation(); | ||
onClose(); | ||
}} | ||
style={{ | ||
position: 'absolute', | ||
top: '20px', | ||
right: '20px', | ||
padding: '8px 16px', | ||
backgroundColor: '#ff4444', | ||
color: 'white', | ||
border: 'none', | ||
borderRadius: '4px', | ||
cursor: 'pointer' | ||
}} | ||
> | ||
Close | ||
</button> | ||
</div> | ||
); | ||
}; | ||
|
||
export default FlappyGoose; |