diff --git a/example/src/App.tsx b/example/src/App.tsx index cb2bf82..56fed33 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,8 +1,7 @@ -import { useState, useEffect, useRef } from "react"; +import { useState } from "react"; import { useGame, type GameMode } from "@vimazing/typing-chud"; import "@vimazing/typing-chud/game.css"; -import { usePlatformHook } from "./usePlatformHook"; -import { testScenarios, getScenario, getScenarioNames, type TestScenario } from "./testStateMachines"; +import { usePlatformHook, useStateMachine, getScenario, getScenarioNames } from "./usePlatformHook"; function App() { const [scenarioId, setScenarioId] = useState("progressive"); @@ -10,58 +9,13 @@ function App() { const [gameMode, setGameMode] = useState(scenario.initialMode); const gameManager = useGame({ gameMode }, usePlatformHook); const { containerRef, gameStatus, scoreManager } = gameManager; - const lastTransitionIndexRef = useRef(-1); - const triggeredScoreTransitionsRef = useRef>(new Set()); - const lastTriggeredModeRef = useRef(null); - useEffect(() => { - if (gameStatus !== "started") return; - - // Handle score-based transitions - if (scenario.scoreTransitions) { - const currentScore = scoreManager.score; - const nextTransition = scenario.scoreTransitions.find( - (t) => currentScore >= t.scoreThreshold && !triggeredScoreTransitionsRef.current.has(t.scoreThreshold) - ); - - if (nextTransition) { - triggeredScoreTransitionsRef.current.add(nextTransition.scoreThreshold); - setGameMode(nextTransition.targetMode); - gameManager.changeGameMode({ gameMode: nextTransition.targetMode }); - } - return; - } - - // Handle count-based transitions - if (!scenario.transitions) return; - - // Skip if we already triggered a transition for this mode - if (lastTriggeredModeRef.current === gameMode) { - return; - } - - const currentTransitionIdx = scenario.transitions.findIndex((t) => t.fromMode === gameMode); - - if (currentTransitionIdx === -1 || currentTransitionIdx <= lastTransitionIndexRef.current) { - return; - } - - const currentTransition = scenario.transitions[currentTransitionIdx]; - const currentCount = scoreManager.currentCount; - - if (currentCount >= currentTransition.threshold) { - lastTransitionIndexRef.current = currentTransitionIdx; - lastTriggeredModeRef.current = gameMode; // Remember we triggered from this mode - setGameMode(currentTransition.targetMode); - gameManager.changeGameMode({ gameMode: currentTransition.targetMode }); - } - }, [scoreManager.currentCount, gameMode, gameManager, scenario, gameStatus, scoreManager.score]); - - useEffect(() => { - lastTransitionIndexRef.current = -1; - triggeredScoreTransitionsRef.current.clear(); - lastTriggeredModeRef.current = null; - }, [scenarioId]); + useStateMachine({ + gameManager, + gameMode, + scenario, + onModeChange: setGameMode, + }); const formatTime = (ms: number) => { const seconds = Math.floor(ms / 1000); diff --git a/example/src/usePlatformHook/index.ts b/example/src/usePlatformHook/index.ts index 8a0689f..3639c88 100644 --- a/example/src/usePlatformHook/index.ts +++ b/example/src/usePlatformHook/index.ts @@ -1 +1,3 @@ export * from './usePlatformHook'; +export * from './useStateMachine'; +export * from './testStateMachines'; diff --git a/example/src/usePlatformHook/testStateMachines.ts b/example/src/usePlatformHook/testStateMachines.ts new file mode 100644 index 0000000..7edbd77 --- /dev/null +++ b/example/src/usePlatformHook/testStateMachines.ts @@ -0,0 +1,167 @@ +import type { GameMode } from '@vimazing/typing-chud'; + +export type ModeTransition = { + fromMode: GameMode; + threshold: number; + targetMode: GameMode; + description: string; +}; + +export type ScoreTransition = { + scoreThreshold: number; + targetMode: GameMode; + description: string; +}; + +export type TestScenario = { + id: string; + name: string; + description: string; + initialMode: GameMode; + transitions?: ModeTransition[]; + scoreTransitions?: ScoreTransition[]; +}; + +export const testScenarios: Record = { + scoreBasedProgression: { + id: 'scoreBasedProgression', + name: 'Score-Based Progression', + description: 'Change modes based on score: score 10 → words, score 50 → sentences, score 100 → paragraphs. Tests delayed application until current item completes.', + initialMode: 'letters', + scoreTransitions: [ + { + scoreThreshold: 10, + targetMode: 'words', + description: 'At score 10, request switch to words', + }, + { + scoreThreshold: 50, + targetMode: 'sentences', + description: 'At score 50, request switch to sentences', + }, + { + scoreThreshold: 100, + targetMode: 'paragraphs', + description: 'At score 100, request switch to paragraphs', + }, + ], + }, + + 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, + })); +} diff --git a/example/src/usePlatformHook/useStateMachine.ts b/example/src/usePlatformHook/useStateMachine.ts new file mode 100644 index 0000000..4f6743b --- /dev/null +++ b/example/src/usePlatformHook/useStateMachine.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef } from "react"; +import type { GameMode, GameManager } from "../../../src"; +import type { TestScenario } from "./testStateMachines"; + +interface UseStateMachineProps { + gameManager: GameManager; + gameMode: GameMode; + scenario: TestScenario; + onModeChange: (newMode: GameMode) => void; +} + +export const useStateMachine = ({ + gameManager, + gameMode, + scenario, + onModeChange, +}: UseStateMachineProps) => { + const lastTransitionIndexRef = useRef(-1); + const triggeredScoreTransitionsRef = useRef>(new Set()); + const lastTriggeredModeRef = useRef(null); + + useEffect(() => { + const { gameStatus, scoreManager } = gameManager; + + if (gameStatus !== "started") return; + + // Handle score-based transitions + if (scenario.scoreTransitions) { + const currentScore = scoreManager.score; + const nextTransition = scenario.scoreTransitions.find( + (t) => currentScore >= t.scoreThreshold && !triggeredScoreTransitionsRef.current.has(t.scoreThreshold) + ); + + if (nextTransition) { + triggeredScoreTransitionsRef.current.add(nextTransition.scoreThreshold); + onModeChange(nextTransition.targetMode); + gameManager.changeGameMode({ gameMode: nextTransition.targetMode }); + } + return; + } + + // Handle count-based transitions + if (!scenario.transitions) return; + + // Skip if we already triggered a transition for this mode + if (lastTriggeredModeRef.current === gameMode) { + return; + } + + const currentTransitionIdx = scenario.transitions.findIndex((t) => t.fromMode === gameMode); + + if (currentTransitionIdx === -1 || currentTransitionIdx <= lastTransitionIndexRef.current) { + return; + } + + const currentTransition = scenario.transitions[currentTransitionIdx]; + const currentCount = scoreManager.currentCount; + + if (currentCount >= currentTransition.threshold) { + lastTransitionIndexRef.current = currentTransitionIdx; + lastTriggeredModeRef.current = gameMode; + onModeChange(currentTransition.targetMode); + gameManager.changeGameMode({ gameMode: currentTransition.targetMode }); + } + }, [scoreManager.currentCount, gameMode, gameManager, scenario, gameStatus, scoreManager.score]); + + useEffect(() => { + lastTransitionIndexRef.current = -1; + triggeredScoreTransitionsRef.current.clear(); + lastTriggeredModeRef.current = null; + }, [scenario.id]); +};