Add test state machines and fix mode tracking for correct threshold detection
This commit is contained in:
+138
-59
@@ -1,19 +1,44 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useGame, type GameMode } from "@vimazing/typing-chud";
|
||||
import "@vimazing/typing-chud/game.css";
|
||||
import { useKeyBindings } from "./useKeyBindings";
|
||||
import { testScenarios, getScenario, getScenarioNames, type TestScenario } from "./testStateMachines";
|
||||
|
||||
function App() {
|
||||
const [gameMode, setGameMode] = useState<GameMode>("letters");
|
||||
const gameManager = useGame({ gameMode }, useKeyBindings);
|
||||
const { containerRef, gameStatus, scoreManager } = gameManager;
|
||||
const [scenarioId, setScenarioId] = useState<string>("progressive");
|
||||
const scenario = getScenario(scenarioId);
|
||||
const [gameMode, setGameMode] = useState<GameMode>(scenario.initialMode);
|
||||
const gameManager = useGame({ gameMode }, useKeyBindings);
|
||||
const { containerRef, gameStatus, scoreManager } = gameManager;
|
||||
const lastTransitionIndexRef = useRef<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (scoreManager.score === 5 && gameMode === "letters") {
|
||||
setGameMode("words");
|
||||
gameManager.changeGameMode({ gameMode: "words" });
|
||||
}
|
||||
}, [scoreManager.score, gameMode, gameManager]);
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "started") return;
|
||||
|
||||
const currentTransitionIdx = scenario.transitions.findIndex((t) => t.fromMode === gameMode);
|
||||
console.log(`[DEBUG] gameMode=${gameMode}, currentTransitionIdx=${currentTransitionIdx}, lastTransitionIndex=${lastTransitionIndexRef.current}`);
|
||||
|
||||
if (currentTransitionIdx === -1 || currentTransitionIdx <= lastTransitionIndexRef.current) {
|
||||
console.log(`[DEBUG] SKIPPING - already processed or no transition found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTransition = scenario.transitions[currentTransitionIdx];
|
||||
const currentCount = scoreManager.currentCount;
|
||||
|
||||
console.log(`[DEBUG] currentCount=${currentCount}, threshold=${currentTransition.threshold}, lettersCompleted=${scoreManager.lettersCompleted}, wordsCompleted=${scoreManager.wordsCompleted}, sentencesCompleted=${scoreManager.sentencesCompleted}`);
|
||||
|
||||
if (currentCount >= currentTransition.threshold) {
|
||||
console.log(`[DEBUG] TRIGGERING TRANSITION: ${currentTransition.fromMode} -> ${currentTransition.targetMode}`);
|
||||
lastTransitionIndexRef.current = currentTransitionIdx;
|
||||
setGameMode(currentTransition.targetMode);
|
||||
gameManager.changeGameMode({ gameMode: currentTransition.targetMode });
|
||||
}
|
||||
}, [scoreManager.currentCount, gameMode, gameManager, scenario, gameStatus, scoreManager.lettersCompleted, scoreManager.wordsCompleted, scoreManager.sentencesCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
lastTransitionIndexRef.current = -1;
|
||||
}, [scenarioId]);
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
@@ -22,56 +47,110 @@ function App() {
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto my-4 w-fit space-y-4">
|
||||
<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={(e) => {
|
||||
setGameMode(mode);
|
||||
gameManager.changeGameMode({ gameMode: mode });
|
||||
e.currentTarget.blur(); // Remove focus from button
|
||||
}}
|
||||
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>
|
||||
return (
|
||||
<div className="relative mx-auto my-4 w-fit space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-center">Typing Chud</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-center">
|
||||
<label className="text-xs text-muted-foreground">Test Scenario:</label>
|
||||
<select
|
||||
value={scenarioId}
|
||||
onChange={(e) => {
|
||||
setScenarioId(e.target.value);
|
||||
const newScenario = getScenario(e.target.value);
|
||||
setGameMode(newScenario.initialMode);
|
||||
gameManager.changeGameMode({ gameMode: newScenario.initialMode });
|
||||
}}
|
||||
className="w-full px-2 py-1 rounded text-sm bg-muted text-foreground border border-muted-foreground"
|
||||
>
|
||||
{getScenarioNames().map(({ id, name }) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">{scenario.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 gap-2 justify-center text-sm font-mono">
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Time</div>
|
||||
<div className="font-bold">{formatTime(scoreManager.timeValue)}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Score</div>
|
||||
<div className="font-bold">{scoreManager.score}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-green-900 rounded">
|
||||
<div className="text-xs text-green-200">Correct</div>
|
||||
<div className="font-bold text-green-100">{scoreManager.correct}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-yellow-900 rounded">
|
||||
<div className="text-xs text-yellow-200">Missed</div>
|
||||
<div className="font-bold text-yellow-100">{scoreManager.missed}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-red-900 rounded">
|
||||
<div className="text-xs text-red-200">Deaths</div>
|
||||
<div className="font-bold text-red-100">{scoreManager.deaths}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Keystrokes</div>
|
||||
<div className="font-bold">{scoreManager.totalKeystrokes}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-muted-foreground mb-2">Mode Selection:</div>
|
||||
<div className="flex gap-2 justify-center text-sm">
|
||||
{(['letters', 'words', 'sentences', 'paragraphs'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={(e) => {
|
||||
setGameMode(mode);
|
||||
gameManager.changeGameMode({ gameMode: mode });
|
||||
e.currentTarget.blur();
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-8 gap-2 justify-center text-sm font-mono">
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Time</div>
|
||||
<div className="font-bold">{formatTime(scoreManager.timeValue)}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Score</div>
|
||||
<div className="font-bold">{scoreManager.score}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-green-900 rounded">
|
||||
<div className="text-xs text-green-200">Correct</div>
|
||||
<div className="font-bold text-green-100">{scoreManager.correct}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-yellow-900 rounded">
|
||||
<div className="text-xs text-yellow-200">Missed</div>
|
||||
<div className="font-bold text-yellow-100">{scoreManager.missed}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-red-900 rounded">
|
||||
<div className="text-xs text-red-200">Deaths</div>
|
||||
<div className="font-bold text-red-100">{scoreManager.deaths}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-purple-900 rounded">
|
||||
<div className="text-xs text-purple-200">Current</div>
|
||||
<div className="font-bold text-purple-100">{scoreManager.currentCount}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-cyan-900 rounded">
|
||||
<div className="text-xs text-cyan-200">Total</div>
|
||||
<div className="font-bold text-cyan-100">{scoreManager.lettersCompleted + scoreManager.wordsCompleted + scoreManager.sentencesCompleted + scoreManager.paragraphsCompleted}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-muted rounded">
|
||||
<div className="text-xs text-muted-foreground">Keystrokes</div>
|
||||
<div className="font-bold">{scoreManager.totalKeystrokes}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 justify-center text-sm font-mono">
|
||||
<div className="px-3 py-1 bg-blue-900 rounded">
|
||||
<div className="text-xs text-blue-200">Letters</div>
|
||||
<div className="font-bold text-blue-100">{scoreManager.lettersCompleted}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-blue-900 rounded">
|
||||
<div className="text-xs text-blue-200">Words</div>
|
||||
<div className="font-bold text-blue-100">{scoreManager.wordsCompleted}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-blue-900 rounded">
|
||||
<div className="text-xs text-blue-200">Sentences</div>
|
||||
<div className="font-bold text-blue-100">{scoreManager.sentencesCompleted}</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-blue-900 rounded">
|
||||
<div className="text-xs text-blue-200">Paragraphs</div>
|
||||
<div className="font-bold text-blue-100">{scoreManager.paragraphsCompleted}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="relative" />
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { GameMode } from '@vimazing/typing-chud';
|
||||
|
||||
export type ModeTransition = {
|
||||
fromMode: GameMode;
|
||||
threshold: number;
|
||||
targetMode: GameMode;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type TestScenario = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
initialMode: GameMode;
|
||||
transitions: ModeTransition[];
|
||||
};
|
||||
|
||||
export const testScenarios: Record<string, TestScenario> = {
|
||||
progressive: {
|
||||
id: 'progressive',
|
||||
name: 'Progressive Mode Ladder',
|
||||
description: 'Automatically progress through all modes: 10 letters → 10 words → 5 sentences → 3 paragraphs',
|
||||
initialMode: 'letters',
|
||||
transitions: [
|
||||
{
|
||||
fromMode: 'letters',
|
||||
threshold: 10,
|
||||
targetMode: 'words',
|
||||
description: 'After 10 letters, switch to words',
|
||||
},
|
||||
{
|
||||
fromMode: 'words',
|
||||
threshold: 10,
|
||||
targetMode: 'sentences',
|
||||
description: 'After 10 words, switch to sentences',
|
||||
},
|
||||
{
|
||||
fromMode: 'sentences',
|
||||
threshold: 5,
|
||||
targetMode: 'paragraphs',
|
||||
description: 'After 5 sentences, switch to paragraphs',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
lettersOnly: {
|
||||
id: 'lettersOnly',
|
||||
name: 'Letters Only',
|
||||
description: 'Stay in letters mode (baseline)',
|
||||
initialMode: 'letters',
|
||||
transitions: [],
|
||||
},
|
||||
|
||||
wordsOnly: {
|
||||
id: 'wordsOnly',
|
||||
name: 'Words Only',
|
||||
description: 'Start in words mode (baseline)',
|
||||
initialMode: 'words',
|
||||
transitions: [],
|
||||
},
|
||||
|
||||
sentencesOnly: {
|
||||
id: 'sentencesOnly',
|
||||
name: 'Sentences Only',
|
||||
description: 'Start in sentences mode (baseline)',
|
||||
initialMode: 'sentences',
|
||||
transitions: [],
|
||||
},
|
||||
|
||||
paragraphsOnly: {
|
||||
id: 'paragraphsOnly',
|
||||
name: 'Paragraphs Only',
|
||||
description: 'Start in paragraphs mode (baseline)',
|
||||
initialMode: 'paragraphs',
|
||||
transitions: [],
|
||||
},
|
||||
|
||||
rapidTransitions: {
|
||||
id: 'rapidTransitions',
|
||||
name: 'Rapid Transitions',
|
||||
description: 'Quick transitions: 3 letters → 3 words → 2 sentences → 1 paragraph',
|
||||
initialMode: 'letters',
|
||||
transitions: [
|
||||
{
|
||||
fromMode: 'letters',
|
||||
threshold: 3,
|
||||
targetMode: 'words',
|
||||
description: 'After 3 letters, switch to words',
|
||||
},
|
||||
{
|
||||
fromMode: 'words',
|
||||
threshold: 3,
|
||||
targetMode: 'sentences',
|
||||
description: 'After 3 words, switch to sentences',
|
||||
},
|
||||
{
|
||||
fromMode: 'sentences',
|
||||
threshold: 2,
|
||||
targetMode: 'paragraphs',
|
||||
description: 'After 2 sentences, switch to paragraphs',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
backAndForth: {
|
||||
id: 'backAndForth',
|
||||
name: 'Back and Forth',
|
||||
description: 'Oscillate between modes: 5 letters ↔ 5 words ↔ 5 letters',
|
||||
initialMode: 'letters',
|
||||
transitions: [
|
||||
{
|
||||
fromMode: 'letters',
|
||||
threshold: 5,
|
||||
targetMode: 'words',
|
||||
description: 'After 5 letters, switch to words',
|
||||
},
|
||||
{
|
||||
fromMode: 'words',
|
||||
threshold: 5,
|
||||
targetMode: 'letters',
|
||||
description: 'After 5 words, switch back to letters',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getScenario(id: string): TestScenario {
|
||||
return testScenarios[id] || testScenarios.progressive;
|
||||
}
|
||||
|
||||
export function getScenarioNames(): { id: string; name: string }[] {
|
||||
return Object.entries(testScenarios).map(([id, scenario]) => ({
|
||||
id,
|
||||
name: scenario.name,
|
||||
}));
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export type GameStatusManager = {
|
||||
wordsCompleted: number;
|
||||
sentencesCompleted: number;
|
||||
paragraphsCompleted: number;
|
||||
currentGameMode: GameMode;
|
||||
getCurrentCount: () => number;
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: Fal
|
||||
const [wordsCompleted, setWordsCompleted] = useState(0);
|
||||
const [sentencesCompleted, setSentencesCompleted] = useState(0);
|
||||
const [paragraphsCompleted, setParagraphsCompleted] = useState(0);
|
||||
const [currentGameMode, setCurrentGameMode] = useState<GameMode>(gameMode);
|
||||
const gameLoopRef = useRef<number | null>(null);
|
||||
const lastFrameTimeRef = useRef<number>(0);
|
||||
const lettersRef = useRef<FallingLetter[]>([]);
|
||||
@@ -325,35 +326,43 @@ export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: Fal
|
||||
onGameLoopTickRef.current = callback;
|
||||
};
|
||||
|
||||
const setGameMode = (mode: GameMode) => {
|
||||
gameModeRef.current = mode;
|
||||
};
|
||||
const setGameMode = (mode: GameMode) => {
|
||||
gameModeRef.current = mode;
|
||||
setCurrentGameMode(mode);
|
||||
};
|
||||
|
||||
const setPendingOptions = (newOptions: GameOptions) => {
|
||||
pendingOptionsRef.current = newOptions;
|
||||
|
||||
// Update currentGameMode immediately so getCurrentCount() returns the correct value
|
||||
if (newOptions.gameMode) {
|
||||
setCurrentGameMode(newOptions.gameMode);
|
||||
gameModeRef.current = newOptions.gameMode;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingOptions = () => {
|
||||
if (!pendingOptionsRef.current) return;
|
||||
|
||||
const pending = pendingOptionsRef.current;
|
||||
pendingOptionsRef.current = null;
|
||||
|
||||
// Update game mode
|
||||
if (pending.gameMode) {
|
||||
gameModeRef.current = pending.gameMode;
|
||||
}
|
||||
|
||||
// Update speed ref
|
||||
if (pending.downwardSpeed !== undefined) {
|
||||
downwardSpeedRef.current = pending.downwardSpeed;
|
||||
}
|
||||
|
||||
// Update word list ref
|
||||
if (pending.wordList !== undefined) {
|
||||
wordListRef.current = pending.wordList;
|
||||
}
|
||||
};
|
||||
const applyPendingOptions = () => {
|
||||
if (!pendingOptionsRef.current) return;
|
||||
|
||||
const pending = pendingOptionsRef.current;
|
||||
pendingOptionsRef.current = null;
|
||||
|
||||
// Update game mode
|
||||
if (pending.gameMode) {
|
||||
gameModeRef.current = pending.gameMode;
|
||||
setCurrentGameMode(pending.gameMode);
|
||||
}
|
||||
|
||||
// Update speed ref
|
||||
if (pending.downwardSpeed !== undefined) {
|
||||
downwardSpeedRef.current = pending.downwardSpeed;
|
||||
}
|
||||
|
||||
// Update word list ref
|
||||
if (pending.wordList !== undefined) {
|
||||
wordListRef.current = pending.wordList;
|
||||
}
|
||||
};
|
||||
|
||||
const addCorrect = () => {
|
||||
setScore(prev => prev + 1);
|
||||
@@ -382,7 +391,7 @@ export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: Fal
|
||||
};
|
||||
|
||||
const getCurrentCount = (): number => {
|
||||
const mode = gameModeRef.current;
|
||||
const mode = currentGameMode;
|
||||
if (mode === 'letters') {
|
||||
return lettersCompleted;
|
||||
} else if (mode === 'words') {
|
||||
@@ -419,6 +428,7 @@ export function useGameStatus(_onGameLoopTick?: (deltaTime: number, letters: Fal
|
||||
wordsCompleted,
|
||||
sentencesCompleted,
|
||||
paragraphsCompleted,
|
||||
currentGameMode,
|
||||
getCurrentCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export function useScore(gameStatusManager: any): ScoreManager {
|
||||
return () => clearInterval(interval);
|
||||
}, [gameStatusManager.gameStatus]);
|
||||
|
||||
const currentCount = gameStatusManager.getCurrentCount();
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
totalKeystrokes: 0, // TODO: Track keystrokes from keyboard input
|
||||
@@ -25,7 +27,7 @@ export function useScore(gameStatusManager: any): ScoreManager {
|
||||
wordsCompleted: gameStatusManager.wordsCompleted,
|
||||
sentencesCompleted: gameStatusManager.sentencesCompleted,
|
||||
paragraphsCompleted: gameStatusManager.paragraphsCompleted,
|
||||
currentCount: gameStatusManager.getCurrentCount(),
|
||||
currentCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user