diff --git a/ui/desktop/src/ChatWindow.tsx b/ui/desktop/src/ChatWindow.tsx index 019df9102..b6e68ef47 100644 --- a/ui/desktop/src/ChatWindow.tsx +++ b/ui/desktop/src/ChatWindow.tsx @@ -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'; @@ -55,6 +56,7 @@ function ChatContent({ const [messageMetadata, setMessageMetadata] = useState>({}); const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); + const [showGame, setShowGame] = useState(false); const { messages, @@ -185,7 +187,6 @@ function ChatContent({ const updatedMessages = [...messages.slice(0, -1), newLastMessage]; setMessages(updatedMessages); } - }; return ( @@ -221,7 +222,9 @@ function ChatContent({ {isLoading && (
- +
setShowGame(true)} style={{ cursor: 'pointer' }}> + +
)} {error && ( @@ -260,6 +263,10 @@ function ChatContent({
+ + {showGame && ( + setShowGame(false)} /> + )}
); } @@ -368,7 +375,6 @@ export default function ChatWindow() { - )} diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx new file mode 100644 index 000000000..b0f38167b --- /dev/null +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -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 = ({ onClose }) => { + const canvasRef = useRef(null); + const [gameOver, setGameOver] = useState(false); + const [displayScore, setDisplayScore] = useState(0); + const gooseImages = useRef([]); + 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 ( +
+ + {!imagesReady && ( +
Loading...
+ )} + {gameOver && ( +
+

Game Over!

+

Score: {displayScore}

+

Click or press space to play again

+
+ )} + +
+ ); +}; + +export default FlappyGoose;