Skip to content

Commit

Permalink
feat: flappy goose easter egg (#479)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kvadratni authored Dec 17, 2024
1 parent 13cd298 commit 1327fc4
Show file tree
Hide file tree
Showing 2 changed files with 329 additions and 3 deletions.
12 changes: 9 additions & 3 deletions ui/desktop/src/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ApiKeyWarning } from './components/ApiKeyWarning';
import { askAi } from './utils/askAI';
import WingToWing, { Working } from './components/WingToWing';
import { WelcomeScreen } from './components/WelcomeScreen';
import FlappyGoose from './components/FlappyGoose';

// update this when you want to show the welcome screen again - doesn't have to be an actual version, just anything woudln't have been seen before
const CURRENT_VERSION = '0.0.0';
Expand Down Expand Up @@ -55,6 +56,7 @@ function ChatContent({
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false);

const {
messages,
Expand Down Expand Up @@ -185,7 +187,6 @@ function ChatContent({
const updatedMessages = [...messages.slice(0, -1), newLastMessage];
setMessages(updatedMessages);
}

};

return (
Expand Down Expand Up @@ -221,7 +222,9 @@ function ChatContent({
</div>
{isLoading && (
<div className="flex items-center justify-center p-4">
<LoadingGoose />
<div onClick={() => setShowGame(true)} style={{ cursor: 'pointer' }}>
<LoadingGoose />
</div>
</div>
)}
{error && (
Expand Down Expand Up @@ -260,6 +263,10 @@ function ChatContent({
<div className="self-stretch h-px bg-black/5 dark:bg-white/5 rounded-sm" />
<BottomMenu hasMessages={hasMessages} />
</Card>

{showGame && (
<FlappyGoose onClose={() => setShowGame(false)} />
)}
</div>
);
}
Expand Down Expand Up @@ -368,7 +375,6 @@ export default function ChatWindow() {
</div>

<WingToWing onExpand={toggleMode} progressMessage={progressMessage} working={working} />

</>
)}
</div>
Expand Down
320 changes: 320 additions & 0 deletions ui/desktop/src/components/FlappyGoose.tsx
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;

0 comments on commit 1327fc4

Please sign in to comment.