From 32045c54732533bea8cb00959fcfdbde78f9fc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 3 Nov 2025 22:38:10 +0000 Subject: [PATCH] feat: add typing mechanics with visual feedback and safe margins Implement core typing game mechanics with keyboard input handling. Correct letters trigger explosion animation with radiating particles and spawn new letter. Wrong letters flash red for immediate feedback. Add 50px margins on both edges to prevent spaceship and letters from appearing at screen borders. --- example/src/useKeyBindings.ts | 11 +++++-- src/types.ts | 8 ++++- src/useBoard/useBoard.ts | 51 +++++++++++++++++++++++++++--- src/useCursor/useCursor.ts | 10 +++--- src/useGame/useGame.ts | 1 + src/useGameStatus/useGameStatus.ts | 33 ++++++++++++++++++- 6 files changed, 101 insertions(+), 13 deletions(-) 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, }; }