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
This commit is contained in:
2025-11-03 23:10:00 +00:00
parent 7ee42a8dc1
commit 662557c933
7 changed files with 138 additions and 26 deletions
+25 -3
View File
@@ -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<GameMode>("words");
const gameManager = useGame({ gameMode }, useKeyBindings);
const { containerRef, gameStatus, scoreManager } = gameManager;
const formatTime = (ms: number) => {
@@ -15,7 +17,27 @@ function App() {
return (
<div className="relative mx-auto my-4 w-fit space-y-4">
<h1 className="text-2xl font-bold text-center">Typing Chud</h1>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-center">Typing Chud</h1>
<div className="flex gap-2 justify-center text-sm">
{(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => (
<button
key={mode}
onClick={() => {
setGameMode(mode);
gameManager.quitGame();
}}
className={`px-3 py-1 rounded capitalize font-medium transition ${
gameMode === mode
? 'bg-blue-600 text-white'
: 'bg-muted text-foreground hover:bg-blue-500 hover:text-white'
}`}
>
{mode}
</button>
))}
</div>
</div>
<div className="grid grid-cols-6 gap-2 justify-center text-sm font-mono">
<div className="px-3 py-1 bg-muted rounded">
+1
View File
@@ -5,4 +5,5 @@ export * from './useBoard';
export * from './useScore';
export * from './types';
export * from './gameInfo';
export * from './wordLists';
+5 -1
View File
@@ -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;
};
// ============================================================================
+26 -6
View File
@@ -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);
}
}
});
};
+3 -2
View File
@@ -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<KeyLogEntry[]>([]);
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 = []) => {
+33 -14
View File
@@ -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<GameStatus>("waiting");
const [shipState, setShipState] = useState<ShipState>("normal");
const [shipExplosionTime, setShipExplosionTime] = useState(0);
@@ -24,21 +25,25 @@ export function useGameStatus(onGameLoopTick?: (deltaTime: number, letters: Fall
const lastSpawnRef = useRef<number>(0);
const playerXRef = useRef<number>(CANVAS_WIDTH / 2);
const onGameLoopTickRef = useRef(onGameLoopTick);
const gameModeRef = useRef<GameMode>(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();
+45
View File
@@ -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<T>(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));
}