From 0dc4ecde72fd8994ff8f735aacbae2b18be904d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 3 Nov 2025 22:01:40 +0000 Subject: [PATCH] feat: implement falling letters spawning from top - Letters spawn at random horizontal positions every 1 second - Letters fall at 100px/second from top to bottom - Random A-Z letters rendered in white monospace font - Fix game loop to start properly when startGame called - Remove letters that exit bottom of screen --- src/types.ts | 8 ++-- src/useBoard/useBoard.ts | 11 ++++- src/useGame/useGame.ts | 20 +++++---- src/useGameStatus/useGameStatus.ts | 65 ++++++++++++++++++++++++++---- 4 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/types.ts b/src/types.ts index 29e68a6..8418663 100755 --- a/src/types.ts +++ b/src/types.ts @@ -19,11 +19,11 @@ export const getGamePhase = (status: GameStatus): GamePhase => // ============================================================================ export type PlayerPosition = { x: number }; // 0-9 for 10 columns -export type Word = { +export type FallingLetter = { id: string; - text: string; - x: number; // 0-9 column position - y: number; // vertical position (0 = top, increases downward) + letter: string; + x: number; // 0-800 pixel position + y: number; // 0-600 pixel position }; // ============================================================================ diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index cfaf76f..44f98cd 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -25,7 +25,7 @@ export function useBoard() { canvasRef.current = canvas; }, []); - const renderBoard = (playerPosition: PlayerPosition) => { + const renderBoard = (playerPosition: PlayerPosition, letters: any = []) => { const canvas = canvasRef.current; if (!canvas) return; @@ -72,6 +72,15 @@ export function useBoard() { ctx.lineTo(spaceshipX + 8, spaceshipY + 15); ctx.closePath(); ctx.fill(); + + // Draw falling letters + ctx.fillStyle = "#ffffff"; + ctx.font = "24px monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + letters.forEach((letter: any) => { + ctx.fillText(letter.letter, letter.x, letter.y); + }); }; return { diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index cdbe1fd..d6d6838 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import type { GameManager, GameOptions, KeyLogEntry } from "../types"; import { useBoard } from "../useBoard"; import { useCursor } from "../useCursor"; @@ -13,18 +13,24 @@ export function useGame( const boardManager = useBoard(); const cursorManager = useCursor(); - const gameStatusManager = useGameStatus(); - const scoreManager = useScore(gameStatusManager); // Wrap renderBoard to include player position - const renderBoardWrapped = () => { - boardManager.renderBoard(cursorManager.position()); - }; + const renderBoardWrapped = useCallback((letters: any = []) => { + boardManager.renderBoard(cursorManager.position(), letters); + }, [boardManager, cursorManager]); + + // Game loop tick handler + const handleGameLoopTick = useCallback((_deltaTime: number, letters: any) => { + renderBoardWrapped(letters); + }, [renderBoardWrapped]); + + const gameStatusManager = useGameStatus(handleGameLoopTick); + const scoreManager = useScore(gameStatusManager); // Initial render on mount useEffect(() => { renderBoardWrapped(); - }, []); + }, [renderBoardWrapped]); const gameManager: GameManager = { containerRef: boardManager.containerRef, diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index e4ade85..adb8d96 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -1,16 +1,67 @@ -import { useState } from "react"; -import type { GameStatus } from "../types"; +import { useState, useRef, useCallback } from "react"; +import type { GameStatus, FallingLetter } from "../types"; -export function useGameStatus() { +const LETTER_SPEED = 100; // pixels per second +const SPAWN_INTERVAL = 1000; // ms between letter spawns + +export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void) { const [gameStatus, setGameStatus] = useState("waiting"); + const gameLoopRef = useRef(null); + const lastFrameTimeRef = useRef(0); + const lettersRef = useRef([]); + const lastSpawnRef = useRef(0); - const startGame = () => { + // Game loop + const gameLoop = useCallback((currentTime: number) => { + const deltaTime = currentTime - lastFrameTimeRef.current; + lastFrameTimeRef.current = currentTime; + + // Spawn new letter if enough time has passed + if (currentTime - lastSpawnRef.current > SPAWN_INTERVAL) { + const randomLetter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); // A-Z + const randomX = Math.random() * 800; + const newLetter: FallingLetter = { + id: `${currentTime}-${randomX}`, + letter: randomLetter, + x: randomX, + y: 0, + }; + lettersRef.current.push(newLetter); + lastSpawnRef.current = currentTime; + } + + // Update letter positions + lettersRef.current.forEach(letter => { + letter.y += (LETTER_SPEED * deltaTime) / 1000; + }); + + // Remove letters that went off screen + lettersRef.current = lettersRef.current.filter(letter => letter.y < 600); + + // Call tick callback if provided + if (onGameLoopTick) { + onGameLoopTick(deltaTime, lettersRef.current); + } + + // Continue loop + gameLoopRef.current = requestAnimationFrame(gameLoop); + }, [onGameLoopTick]); + + const startGame = useCallback(() => { + lastFrameTimeRef.current = performance.now(); + lastSpawnRef.current = performance.now(); + lettersRef.current = []; setGameStatus("started"); - }; + gameLoopRef.current = requestAnimationFrame(gameLoop); + }, [gameLoop]); - const quitGame = () => { + const quitGame = useCallback(() => { setGameStatus("waiting"); - }; + if (gameLoopRef.current !== null) { + cancelAnimationFrame(gameLoopRef.current); + gameLoopRef.current = null; + } + }, []); return { gameStatus,