diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts index 4dfdf0b..986d64d 100755 --- a/example/src/useKeyBindings.ts +++ b/example/src/useKeyBindings.ts @@ -2,7 +2,7 @@ import { useEffect } from "react"; import type { GameManager } from "../../src"; export const useKeyBindings = (gameManager: GameManager) => { - const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog } = gameManager; + const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog, handleTypedLetter } = gameManager; useEffect(() => { const handler = (ev: KeyboardEvent) => { @@ -29,10 +29,17 @@ export const useKeyBindings = (gameManager: GameManager) => { quitGame(); return; } + + // handle letter typing during game + if (gameStatus === "started") { + if (ev.key.length === 1 && /[a-zA-Z]/.test(ev.key)) { + handleTypedLetter(ev.key); + } + } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [gameStatus]); + }, [gameStatus, handleTypedLetter]); }; diff --git a/src/types.ts b/src/types.ts index 4dffbe1..7925bcd 100755 --- a/src/types.ts +++ b/src/types.ts @@ -19,11 +19,16 @@ export const getGamePhase = (status: GameStatus): GamePhase => // ============================================================================ export type PlayerPosition = { x: number }; // pixel position 0-800 + +export type LetterState = 'normal' | 'wrong' | 'exploding'; + export type FallingLetter = { id: string; letter: string; x: number; // 0-800 pixel position y: number; // 0-600 pixel position + state: LetterState; + stateStartTime?: number; // For animation timing }; // ============================================================================ @@ -126,7 +131,8 @@ export type GameManager = { clearKeyLog: () => void; getKeyLog: () => KeyLogEntry[]; - // TODO: Add game-specific additions + // Game-specific + handleTypedLetter: (letter: string) => void; }; // ============================================================================ diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 170fbfc..7df8f25 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -132,13 +132,54 @@ export function useBoard() { ctx.closePath(); ctx.fill(); - // Draw falling letters - ctx.fillStyle = "#ffffff"; - ctx.font = "24px monospace"; + // Draw falling letters with state effects + ctx.font = "bold 32px monospace"; ctx.textAlign = "center"; - ctx.textBaseline = "top"; + ctx.textBaseline = "middle"; + letters.forEach((letter: any) => { - ctx.fillText(letter.letter, letter.x, letter.y); + if (letter.state === 'wrong') { + // Red glow flash + ctx.shadowBlur = 20; + ctx.shadowColor = "#ff0000"; + ctx.fillStyle = "#ff4444"; + ctx.fillText(letter.letter, letter.x, letter.y); + ctx.shadowBlur = 0; + } else if (letter.state === 'exploding') { + // Explosion effect + const elapsed = performance.now() - (letter.stateStartTime || 0); + const progress = Math.min(elapsed / 300, 1); + const scale = 1 + progress * 2; + const opacity = 1 - progress; + + ctx.save(); + ctx.translate(letter.x, letter.y); + ctx.scale(scale, scale); + ctx.globalAlpha = opacity; + + // Explosion particles + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + const distance = progress * 30; + const px = Math.cos(angle) * distance; + const py = Math.sin(angle) * distance; + + ctx.fillStyle = i % 2 === 0 ? "#ffff00" : "#ff6600"; + ctx.beginPath(); + ctx.arc(px, py, 3, 0, Math.PI * 2); + ctx.fill(); + } + + // Letter fading + ctx.fillStyle = "#ffffff"; + ctx.fillText(letter.letter, 0, 0); + + ctx.restore(); + } else { + // Normal state + ctx.fillStyle = "#ffffff"; + ctx.fillText(letter.letter, letter.x, letter.y); + } }); }; diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index 9889ae1..8ee9c6c 100755 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -1,6 +1,8 @@ import { useRef } from "react"; const CANVAS_WIDTH = 800; +const MARGIN_LEFT = 50; +const MARGIN_RIGHT = 50; const START_PIXEL_X = CANVAS_WIDTH / 2; // Start at center (400px) const MOVEMENT_DURATION_MS = 100; // Time to reach target regardless of distance @@ -11,13 +13,13 @@ export function useCursor() { const movementStartTimeRef = useRef(0); const moveLeft = (count: number = 1) => { - const newX = Math.max(0, pixelXRef.current - (count * 80)); + const newX = Math.max(MARGIN_LEFT, pixelXRef.current - (count * 80)); pixelXRef.current = newX; targetXRef.current = newX; }; const moveRight = (count: number = 1) => { - const newX = Math.min(CANVAS_WIDTH, pixelXRef.current + (count * 80)); + const newX = Math.min(CANVAS_WIDTH - MARGIN_RIGHT, pixelXRef.current + (count * 80)); pixelXRef.current = newX; targetXRef.current = newX; }; @@ -31,13 +33,13 @@ export function useCursor() { }; const moveToStart = () => { - const newX = 0; + const newX = MARGIN_LEFT; pixelXRef.current = newX; targetXRef.current = newX; }; const moveToEnd = () => { - const newX = CANVAS_WIDTH; + const newX = CANVAS_WIDTH - MARGIN_RIGHT; pixelXRef.current = newX; targetXRef.current = newX; }; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 3eae488..8bead80 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -50,6 +50,7 @@ export function useGame( keyLog, clearKeyLog: () => setKeyLog([]), getKeyLog: () => [...keyLog], + handleTypedLetter: gameStatusManager.handleTypedLetter, }; if (typeof platformHook === 'function') { diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index 2d230f4..ae17e1e 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -4,6 +4,9 @@ import type { GameStatus, FallingLetter } from "../types"; const LETTER_SPEED = 100; // pixels per second const SPAWN_INTERVAL = 1000; // ms between letter spawns const MAX_ACTIVE_LETTERS = 1; // Only one letter on screen at a time +const MARGIN_LEFT = 50; // Safe margin from left edge +const MARGIN_RIGHT = 50; // Safe margin from right edge +const CANVAS_WIDTH = 800; export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void) { const [gameStatus, setGameStatus] = useState("waiting"); @@ -20,13 +23,15 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall // Spawn new letter if enough time has passed and no letters active if (currentTime - lastSpawnRef.current > SPAWN_INTERVAL && lettersRef.current.length < MAX_ACTIVE_LETTERS) { const randomLetter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); // A-Z - const randomX = Math.random() * 800; + const spawnWidth = CANVAS_WIDTH - MARGIN_LEFT - MARGIN_RIGHT; + const randomX = MARGIN_LEFT + Math.random() * spawnWidth; const randomY = Math.random() * 150; // Between 0-150 (top half of screen) const newLetter: FallingLetter = { id: `${currentTime}-${randomX}`, letter: randomLetter, x: randomX, y: randomY, + state: 'normal', }; lettersRef.current.push(newLetter); lastSpawnRef.current = currentTime; @@ -65,11 +70,37 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall } }, []); + const handleTypedLetter = useCallback((typedLetter: string) => { + if (lettersRef.current.length === 0) return; + + const currentLetter = lettersRef.current[0]; + + if (typedLetter.toUpperCase() === currentLetter.letter) { + currentLetter.state = 'exploding'; + currentLetter.stateStartTime = performance.now(); + + setTimeout(() => { + lettersRef.current = []; + lastSpawnRef.current = 0; + }, 300); + } else { + currentLetter.state = 'wrong'; + currentLetter.stateStartTime = performance.now(); + + setTimeout(() => { + if (currentLetter.state === 'wrong') { + currentLetter.state = 'normal'; + } + }, 200); + } + }, []); + return { gameStatus, setGameStatus, startGame, quitGame, + handleTypedLetter, }; }