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:
+25
-3
@@ -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">
|
||||
|
||||
@@ -5,4 +5,5 @@ export * from './useBoard';
|
||||
export * from './useScore';
|
||||
export * from './types';
|
||||
export * from './gameInfo';
|
||||
export * from './wordLists';
|
||||
|
||||
|
||||
+5
-1
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 = []) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user