Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | import { useRef, useState, useCallback, useEffect } from 'react' import type { ChallengeState, ChallengePhase } from './types' import type { WordProblem, DifficultyLevel } from '../wordProblems/types' import type { RulerState } from '../ruler/types' import type { CoordinatePlaneState } from '../types' import { generateWordProblem } from '../wordProblems/generate' import { checkAnswer } from './answerCheck' const PRESENT_DELAY_MS = 300 const CELEBRATE_DURATION_MS = 600 const REVEAL_STEP_MS = 500 interface UseChallengeOptions { rulerRef: React.MutableRefObject<RulerState> stateRef: React.MutableRefObject<CoordinatePlaneState> rulerVersion: number enabled: boolean onComplete?: (problem: WordProblem, attempts: number) => void /** Called when a new problem is summoned, to trigger viewport animation */ onSummon?: (problem: WordProblem) => void } export function useChallenge({ rulerRef, stateRef, rulerVersion, enabled, onComplete, onSummon, }: UseChallengeOptions) { const challengeRef = useRef<ChallengeState>({ phase: 'idle', problem: null, attempts: 0, phaseStartTime: 0, revealStep: 0, }) const [challengeVersion, setChallengeVersion] = useState(0) const updatePhase = useCallback((phase: ChallengePhase) => { challengeRef.current.phase = phase challengeRef.current.phaseStartTime = performance.now() setChallengeVersion((v) => v + 1) }, []) /** Summon a new problem */ const summonProblem = useCallback( (difficulty: DifficultyLevel = 3) => { const seed = Math.floor(Math.random() * 0xffffffff) const problem = generateWordProblem(seed, difficulty) challengeRef.current.problem = problem challengeRef.current.attempts = 0 challengeRef.current.revealStep = 0 // Start with auto-adjusting phase (viewport animation) updatePhase('auto-adjusting') onSummon?.(problem) // After a brief delay for viewport animation, transition to presenting setTimeout(() => { if (challengeRef.current.phase === 'auto-adjusting') { updatePhase('presenting') // After slide-in animation, transition to solving setTimeout(() => { if (challengeRef.current.phase === 'presenting') { updatePhase('solving') } }, PRESENT_DELAY_MS) } }, 600) // viewport animation duration }, [enabled, updatePhase, onSummon] ) /** Dismiss the current problem */ const dismissProblem = useCallback(() => { updatePhase('idle') challengeRef.current.problem = null }, [updatePhase]) /** Check the answer when ruler changes during solving phase */ useEffect(() => { if (challengeRef.current.phase !== 'solving') return if (!challengeRef.current.problem) return const ruler = rulerRef.current const problem = challengeRef.current.problem const { correct } = checkAnswer(ruler.ax, ruler.ay, ruler.bx, ruler.by, problem) if (correct) { challengeRef.current.attempts += 1 updatePhase('answering') } }, [rulerVersion, updatePhase]) // eslint-disable-line react-hooks/exhaustive-deps /** Step through reveal annotations one at a time */ const startRevealSequence = useCallback(() => { const problem = challengeRef.current.problem if (!problem) return const annotatedTags = problem.spans.filter( (s) => s.tag && s.tag !== 'context' && s.tag !== 'question' ) const totalSteps = annotatedTags.length let step = 0 const interval = setInterval(() => { step++ challengeRef.current.revealStep = step setChallengeVersion((v) => v + 1) if (step >= totalSteps) { clearInterval(interval) updatePhase('revealed') onComplete?.(problem, challengeRef.current.attempts) } }, REVEAL_STEP_MS) return () => clearInterval(interval) }, [updatePhase, onComplete]) /** Called when the kid enters the correct x answer — fires onComplete and summons next problem */ const handleAnswerCorrect = useCallback( (difficulty: DifficultyLevel = 3) => { const problem = challengeRef.current.problem if (!problem) return onComplete?.(problem, challengeRef.current.attempts) // Brief celebration, then summon next problem updatePhase('celebrating') setTimeout(() => { if (challengeRef.current.phase === 'celebrating') { summonProblem(difficulty) } }, CELEBRATE_DURATION_MS) }, [updatePhase, onComplete, summonProblem] ) return { challengeRef, challengeVersion, summonProblem, dismissProblem, handleAnswerCorrect, phase: challengeRef.current.phase, problem: challengeRef.current.problem, } } |