diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 604b95f7..de9f90e9 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -794,8 +794,14 @@ export function MapRenderer({ : selectedContinent !== 'all' ? (selectedContinent as HintMap) : 'world' - // Get hint for current region (if available) - const hintText = useRegionHint(hintMapKey, currentPrompt) + // Get hint for current region (if available) - with cycling support + const { + currentHint: hintText, + hintIndex, + totalHints, + nextHint, + hasMoreHints, + } = useRegionHint(hintMapKey, currentPrompt) const hasHint = useHasRegionHint(hintMapKey, currentPrompt) // Get the current region name for audio hints @@ -1024,14 +1030,14 @@ export function MapRenderer({ if (delay > 0) { // Schedule delayed announcement announcementTimeoutRef.current = setTimeout(() => { - speakWithRegionName(currentRegionName, null, false) + speakWithRegionName(currentRegionName, hintText, false) announcementTimeoutRef.current = null }, delay) } else { // No recent "You found", announce immediately - speakWithRegionName(currentRegionName, null, false) + speakWithRegionName(currentRegionName, hintText, false) } - }, [currentPrompt, currentRegionName, isSpeechSupported, speakWithRegionName]) + }, [currentPrompt, currentRegionName, hintText, isSpeechSupported, speakWithRegionName]) // Part 2: Announce "You found {region}" at the START of part 2 // - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in) @@ -1123,6 +1129,65 @@ export function MapRenderer({ resetHotCold() }, [currentPrompt, resetHotCold]) + // Struggle detection: Give additional hints when user is having trouble + // Thresholds for triggering next hint (must have more hints available) + const STRUGGLE_TIME_THRESHOLD = 30000 // 30 seconds per hint level + const STRUGGLE_CHECK_INTERVAL = 5000 // Check every 5 seconds + const lastHintLevelRef = useRef(0) // Track which hint level triggered last hint + + useEffect(() => { + // Only run if hot/cold is enabled (means we're tracking search metrics) + if (!effectiveHotColdEnabled || !hasMoreHints || !currentPrompt) return + + const checkStruggle = () => { + const metrics = getSearchMetrics(promptStartTime.current) + + // Calculate which hint level we should be at based on time + // Level 0 = first 30 seconds, Level 1 = 30-60 seconds, etc. + const expectedHintLevel = Math.floor(metrics.timeToFind / STRUGGLE_TIME_THRESHOLD) + + // If we should be at a higher hint level than last triggered, give next hint + if (expectedHintLevel > lastHintLevelRef.current && hasMoreHints) { + lastHintLevelRef.current = expectedHintLevel + nextHint() + } + } + + const intervalId = setInterval(checkStruggle, STRUGGLE_CHECK_INTERVAL) + + return () => clearInterval(intervalId) + }, [ + effectiveHotColdEnabled, + hasMoreHints, + currentPrompt, + getSearchMetrics, + nextHint, + promptStartTime, + ]) + + // Reset hint level tracking when prompt changes + useEffect(() => { + lastHintLevelRef.current = 0 + }, [currentPrompt]) + + // Speak new hint when it changes (due to cycling) + const prevHintIndexRef = useRef(hintIndex) + useEffect(() => { + // Only speak if hint index actually changed (not initial render) + if ( + hintIndex > prevHintIndexRef.current && + hintText && + isSpeechSupported && + currentRegionName + ) { + prevHintIndexRef.current = hintIndex + // Speak just the hint (user already knows the region name) + speak(hintText, false) + } else if (hintIndex !== prevHintIndexRef.current) { + prevHintIndexRef.current = hintIndex + } + }, [hintIndex, hintText, isSpeechSupported, currentRegionName, speak]) + // Update context with controls state for GameInfoPanel useEffect(() => { setControlsState({ @@ -4202,11 +4267,7 @@ export function MapRenderer({ }} > {/* North indicator - red triangle pointing up */} - + @@ -4336,7 +4397,13 @@ export function MapRenderer({ ) })} {/* Center dot */} - + {/* Counter-rotating group to keep N fixed pointing up */} {/* North indicator - red triangle pointing up */} - + diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts b/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts index d7e7380c..1197f5e6 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts @@ -1,53 +1,99 @@ -import { useMemo, useRef } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useLocale } from 'next-intl' import { getHints, hasHints, type HintMap } from '../messages' import type { Locale } from '@/i18n/messages' /** - * Hook to get a randomly selected hint for a region in the current locale. - * The hint is selected once when the region changes and stays consistent - * for that region prompt. Different hints may appear on subsequent attempts - * at the same region. + * Hook to get hints for a region with cycling support. + * Starts with a random hint, and can cycle to the next hint when user is struggling. * - * Returns null if no hints exist for the region. + * Returns: + * - currentHint: The current hint text (null if no hints) + * - hintIndex: Current index (0-based) + * - totalHints: Total number of hints available + * - nextHint: Function to advance to the next hint (cycles) + * - hasMoreHints: Whether there are hints the user hasn't seen yet */ -export function useRegionHint(map: HintMap, regionId: string | null): string | null { +export interface UseRegionHintResult { + currentHint: string | null + hintIndex: number + totalHints: number + nextHint: () => void + hasMoreHints: boolean +} + +export function useRegionHint(map: HintMap, regionId: string | null): UseRegionHintResult { const locale = useLocale() as Locale - // Track which region we last selected a hint for + // Track which region we're showing hints for const lastRegionRef = useRef(null) - const selectedHintRef = useRef(null) + // Track the starting index (randomized on region change) + const startIndexRef = useRef(0) + // Track how many hints we've cycled through for this region + const [cycleOffset, setCycleOffset] = useState(0) - // When region changes, select a new random hint - const hint = useMemo(() => { - if (!regionId) { - lastRegionRef.current = null - selectedHintRef.current = null - return null - } - - const hints = getHints(locale, map, regionId) - if (!hints || hints.length === 0) { - lastRegionRef.current = regionId - selectedHintRef.current = null - return null - } - - // If same region, return previously selected hint - if (regionId === lastRegionRef.current && selectedHintRef.current !== null) { - return selectedHintRef.current - } - - // New region - select a random hint - const randomIndex = Math.floor(Math.random() * hints.length) - const selected = hints[randomIndex] - - lastRegionRef.current = regionId - selectedHintRef.current = selected - - return selected + // Get all hints for this region + const allHints = useMemo(() => { + if (!regionId) return [] + return getHints(locale, map, regionId) ?? [] }, [locale, map, regionId]) - return hint + // Reset cycle offset when region changes + if (regionId !== lastRegionRef.current) { + lastRegionRef.current = regionId + // Pick a random starting index for this region + startIndexRef.current = allHints.length > 0 ? Math.floor(Math.random() * allHints.length) : 0 + // Reset cycle offset (can't call setState in render, so we check in useMemo below) + } + + // Calculate current hint + const result = useMemo(() => { + // Reset cycle offset if region changed (detected by mismatch) + const effectiveCycleOffset = regionId === lastRegionRef.current ? cycleOffset : 0 + + if (allHints.length === 0) { + return { + currentHint: null, + hintIndex: 0, + totalHints: 0, + hasMoreHints: false, + } + } + + const currentIndex = (startIndexRef.current + effectiveCycleOffset) % allHints.length + return { + currentHint: allHints[currentIndex], + hintIndex: effectiveCycleOffset, + totalHints: allHints.length, + hasMoreHints: effectiveCycleOffset < allHints.length - 1, + } + }, [allHints, cycleOffset, regionId]) + + // Reset cycle offset when region changes + const prevRegionRef = useRef(null) + if (regionId !== prevRegionRef.current) { + prevRegionRef.current = regionId + if (cycleOffset !== 0) { + setCycleOffset(0) + } + } + + // Function to advance to the next hint + const nextHint = useCallback(() => { + if (allHints.length > 1) { + setCycleOffset((prev) => prev + 1) + } + }, [allHints.length]) + + return { + ...result, + nextHint, + } +} + +// Legacy function signature for backwards compatibility +export function useRegionHintSimple(map: HintMap, regionId: string | null): string | null { + const { currentHint } = useRegionHint(map, regionId) + return currentHint } /**