Add test state machines and fix mode tracking for correct threshold detection

This commit is contained in:
2025-11-04 20:46:13 +00:00
parent c147fd42d2
commit 598c39e24a
5 changed files with 313 additions and 85 deletions
+138 -59
View File
@@ -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" />
+136
View File
@@ -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,
}));
}
+1
View File
@@ -147,6 +147,7 @@ export type GameStatusManager = {
wordsCompleted: number;
sentencesCompleted: number;
paragraphsCompleted: number;
currentGameMode: GameMode;
getCurrentCount: () => number;
};
+35 -25
View File
@@ -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,
};
}
+3 -1
View File
@@ -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,
};
}