From 662557c933e60ba1ebe2f36d30e7744e22225b6d Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 3 Nov 2025 23:10:00 +0000 Subject: [PATCH] feat: implement game modes system with letters and words support - Add GameMode type: 'letters' | 'words' | 'sentences' | 'paragraphs' - Add gameMode option to GameOptions (defaults to 'letters') - Create wordLists.ts with curated word lists for each mode - Update FallingLetter type to track: - fullText: complete word/sentence/paragraph - charIndex: current character progress in word - Modify word spawning to pick from mode-specific word list - Update handleTypedLetter to support multi-character typed sequences: - Track progress through word (charIndex) - Advance to next character when correct - Show complete word only when fully typed - Enhanced canvas rendering with color-coded progress: - Green: completed characters - Yellow: current character being typed - White: remaining characters - Add mode selector buttons to example app - Default to 'words' mode in example for testing - Full TypeScript support for all modes --- example/src/App.tsx | 28 ++++++++++++++++-- src/index.ts | 1 + src/types.ts | 6 +++- src/useBoard/useBoard.ts | 32 ++++++++++++++++---- src/useGame/useGame.ts | 5 ++-- src/useGameStatus/useGameStatus.ts | 47 +++++++++++++++++++++--------- src/wordLists.ts | 45 ++++++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 src/wordLists.ts diff --git a/example/src/App.tsx b/example/src/App.tsx index f1b7839..ae345da 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,9 +1,11 @@ -import { useGame } from "@vimazing/typing-chud"; +import { useState } from "react"; +import { useGame, type GameMode } from "@vimazing/typing-chud"; import "@vimazing/typing-chud/game.css"; import { useKeyBindings } from "./useKeyBindings"; function App() { - const gameManager = useGame({}, useKeyBindings); + const [gameMode, setGameMode] = useState("words"); + const gameManager = useGame({ gameMode }, useKeyBindings); const { containerRef, gameStatus, scoreManager } = gameManager; const formatTime = (ms: number) => { @@ -15,7 +17,27 @@ function App() { return (
-

Typing Chud

+
+

Typing Chud

+
+ {(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => ( + + ))} +
+
diff --git a/src/index.ts b/src/index.ts index 9db4aa9..d0d6580 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export * from './useBoard'; export * from './useScore'; export * from './types'; export * from './gameInfo'; +export * from './wordLists'; diff --git a/src/types.ts b/src/types.ts index 6513c55..b613788 100755 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ import type { RefObject } from 'react'; export type GameStatus = 'waiting' | 'started' | 'game-won' | 'game-over'; export type GamePhase = 'idle' | 'playing'; export type PlayStatus = 'started' | 'game-over' | 'game-won'; +export type GameMode = 'letters' | 'words' | 'sentences' | 'paragraphs'; export const isPlaying = (status: GameStatus): status is PlayStatus => status === 'started' || status === 'game-over' || status === 'game-won'; @@ -30,6 +31,9 @@ export type FallingLetter = { y: number; // 0-600 pixel position state: LetterState; stateStartTime?: number; // For animation timing + // For word/sentence/paragraph modes + fullText?: string; // The complete word/sentence/paragraph + charIndex?: number; // Current character index being typed (0-based) }; // ============================================================================ @@ -39,7 +43,7 @@ export type FallingLetter = { export type KeyLogEntry = { key: string; timestamp: number }; export type GameOptions = { - // TODO: Define game options + gameMode?: GameMode; }; // ============================================================================ diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 40e29ad..4d708ae 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -178,7 +178,10 @@ export function useBoard() { ctx.shadowBlur = 20; ctx.shadowColor = "#ff0000"; ctx.fillStyle = "#ff4444"; - ctx.fillText(letter.letter, letter.x, letter.y); + + // Draw full text or just current letter + const displayText = letter.fullText || letter.letter; + ctx.fillText(displayText, letter.x, letter.y); ctx.shadowBlur = 0; } else if (letter.state === 'exploding') { // Explosion effect @@ -205,15 +208,32 @@ export function useBoard() { ctx.fill(); } - // Letter fading + // Full text fading ctx.fillStyle = "#ffffff"; - ctx.fillText(letter.letter, 0, 0); + const displayText = letter.fullText || letter.letter; + ctx.fillText(displayText, 0, 0); ctx.restore(); } else { - // Normal state - ctx.fillStyle = "#ffffff"; - ctx.fillText(letter.letter, letter.x, letter.y); + // Normal state - show full text with progress indication + const fullText = letter.fullText || letter.letter; + const charIndex = letter.charIndex ?? 0; + + // Draw completed characters in green + if (charIndex > 0) { + ctx.fillStyle = "#00ff00"; + ctx.fillText(fullText.substring(0, charIndex), letter.x, letter.y); + } + + // Draw current character in yellow + ctx.fillStyle = "#ffff00"; + ctx.fillText(fullText[charIndex], letter.x, letter.y); + + // Draw remaining characters in white + if (charIndex + 1 < fullText.length) { + ctx.fillStyle = "#ffffff"; + ctx.fillText(fullText.substring(charIndex + 1), letter.x, letter.y); + } } }); }; diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 58415dc..0cd9809 100755 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -6,14 +6,15 @@ import { useGameStatus } from "../useGameStatus"; import { useScore } from "../useScore"; export function useGame( - _options?: GameOptions, + options?: GameOptions, platformHook?: unknown ): GameManager { const [keyLog, setKeyLog] = useState([]); + const gameMode = options?.gameMode ?? 'letters'; const boardManager = useBoard(); const cursorManager = useCursor(); - const gameStatusManager = useGameStatus(); + const gameStatusManager = useGameStatus(undefined, gameMode); // Wrap renderBoard to include player position const renderBoardWrapped = (letters: any = []) => { diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts index bfcf06e..4224adc 100755 --- a/src/useGameStatus/useGameStatus.ts +++ b/src/useGameStatus/useGameStatus.ts @@ -1,5 +1,6 @@ import { useState, useRef, useCallback } from "react"; -import type { GameStatus, FallingLetter, ShipState } from "../types"; +import type { GameStatus, FallingLetter, ShipState, GameMode } from "../types"; +import { getRandomWord } from "../wordLists"; const LETTER_SPEED = 100; // pixels per second const SPAWN_INTERVAL = 1000; // ms between letter spawns @@ -10,7 +11,7 @@ const CANVAS_WIDTH = 800; const SPACESHIP_Y = 600 - 40; // Position of the ship on canvas const COLLISION_THRESHOLD = 30; // Distance to trigger collision -export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void) { +export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: FallingLetter[]) => void, gameMode: GameMode = 'letters') { const [gameStatus, setGameStatus] = useState("waiting"); const [shipState, setShipState] = useState("normal"); const [shipExplosionTime, setShipExplosionTime] = useState(0); @@ -24,21 +25,25 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall const lastSpawnRef = useRef(0); const playerXRef = useRef(CANVAS_WIDTH / 2); const onGameLoopTickRef = useRef(onGameLoopTick); + const gameModeRef = useRef(gameMode); // Game loop const gameLoop = useCallback((currentTime: number) => { const deltaTime = currentTime - lastFrameTimeRef.current; lastFrameTimeRef.current = currentTime; - // Spawn new letter if enough time has passed and no letters active + // Spawn new letter/word 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 fullText = getRandomWord(gameModeRef.current); + const firstChar = fullText[0].toUpperCase(); 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, + letter: firstChar, + fullText: fullText.toUpperCase(), + charIndex: 0, x: randomX, y: randomY, state: 'normal', @@ -113,17 +118,31 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall if (lettersRef.current.length === 0) return; const currentLetter = lettersRef.current[0]; - - if (typedLetter.toUpperCase() === currentLetter.letter) { - currentLetter.state = 'exploding'; - currentLetter.stateStartTime = performance.now(); + const expectedChar = currentLetter.letter; + const isCorrect = typedLetter.toUpperCase() === expectedChar; + + if (isCorrect) { setScore(prev => prev + 1); setCorrect(prev => prev + 1); - - setTimeout(() => { - lettersRef.current = []; - lastSpawnRef.current = 0; - }, 300); + + const charIndex = (currentLetter.charIndex ?? 0) + 1; + const fullText = currentLetter.fullText ?? currentLetter.letter; + + // Check if word is complete + if (charIndex >= fullText.length) { + // Word complete - explode and clear + currentLetter.state = 'exploding'; + currentLetter.stateStartTime = performance.now(); + + setTimeout(() => { + lettersRef.current = []; + lastSpawnRef.current = 0; + }, 300); + } else { + // More characters to type - advance to next character + currentLetter.charIndex = charIndex; + currentLetter.letter = fullText[charIndex]; + } } else { currentLetter.state = 'wrong'; currentLetter.stateStartTime = performance.now(); diff --git a/src/wordLists.ts b/src/wordLists.ts new file mode 100644 index 0000000..cdea56d --- /dev/null +++ b/src/wordLists.ts @@ -0,0 +1,45 @@ +export const wordLists = { + letters: [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z', + ] as const, + + words: [ + 'apple', 'banana', 'cherry', 'dragon', 'eagle', 'forest', 'guitar', 'horizon', + 'island', 'jungle', 'knight', 'lighthouse', 'mountain', 'nature', 'ocean', + 'palace', 'quantum', 'river', 'sunset', 'temple', 'universe', 'valley', + 'whisper', 'xylophone', 'yellow', 'zenith', 'admiral', 'beacon', 'castle', + 'diamond', 'element', 'fortress', 'galaxy', 'harbor', 'imagine', 'jewel', + 'kingdom', 'legend', 'mansion', 'nebula', 'onyx', 'phantom', 'quartz', + 'rainbow', 'sanctuary', 'tavern', 'utopia', 'velocity', 'wizard', 'youth', + ] as const, + + sentences: [ + 'the quick brown fox', + 'jump over lazy dog', + 'coding is fun today', + 'practice makes perfect', + 'type faster every day', + 'aim for high scores', + 'stay focused always', + 'keep calm and type', + ] as const, + + paragraphs: [ + 'the quick brown fox jumps over the lazy dog', + 'practice typing to improve your speed and accuracy', + 'focus on each word and type with purpose today', + 'consistency in practice leads to better results', + 'challenge yourself with longer text passages', + ] as const, +}; + +export function getRandomItem(array: readonly T[]): T { + return array[Math.floor(Math.random() * array.length)]; +} + +export function getRandomWord(gameMode: 'letters' | 'words' | 'sentences' | 'paragraphs'): string { + const list = wordLists[gameMode]; + return String(getRandomItem(list)); +}