feat(know-your-world): add adaptive hint cycling for struggling users
- Modify useRegionHint hook to support cycling through multiple hints
- Returns { currentHint, hintIndex, totalHints, nextHint, hasMoreHints }
- Starts with random hint, advances on nextHint() call
- Resets cycle when region changes
- Add useRegionHintSimple for backwards compatibility
- Add struggle detection in MapRenderer
- Monitors time spent searching for region
- Gives next hint every 30 seconds of struggle
- Announces new hint via speech synthesis when it changes
- Fix Part 1 hint announcement regression
- Was passing null instead of hintText to speakWithRegionName
- Now correctly announces region name followed by hint text
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e0b762e3ee
commit
54402501e5
|
|
@ -794,8 +794,14 @@ export function MapRenderer({
|
||||||
: selectedContinent !== 'all'
|
: selectedContinent !== 'all'
|
||||||
? (selectedContinent as HintMap)
|
? (selectedContinent as HintMap)
|
||||||
: 'world'
|
: 'world'
|
||||||
// Get hint for current region (if available)
|
// Get hint for current region (if available) - with cycling support
|
||||||
const hintText = useRegionHint(hintMapKey, currentPrompt)
|
const {
|
||||||
|
currentHint: hintText,
|
||||||
|
hintIndex,
|
||||||
|
totalHints,
|
||||||
|
nextHint,
|
||||||
|
hasMoreHints,
|
||||||
|
} = useRegionHint(hintMapKey, currentPrompt)
|
||||||
const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
|
const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
|
||||||
|
|
||||||
// Get the current region name for audio hints
|
// Get the current region name for audio hints
|
||||||
|
|
@ -1024,14 +1030,14 @@ export function MapRenderer({
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
// Schedule delayed announcement
|
// Schedule delayed announcement
|
||||||
announcementTimeoutRef.current = setTimeout(() => {
|
announcementTimeoutRef.current = setTimeout(() => {
|
||||||
speakWithRegionName(currentRegionName, null, false)
|
speakWithRegionName(currentRegionName, hintText, false)
|
||||||
announcementTimeoutRef.current = null
|
announcementTimeoutRef.current = null
|
||||||
}, delay)
|
}, delay)
|
||||||
} else {
|
} else {
|
||||||
// No recent "You found", announce immediately
|
// 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
|
// Part 2: Announce "You found {region}" at the START of part 2
|
||||||
// - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in)
|
// - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in)
|
||||||
|
|
@ -1123,6 +1129,65 @@ export function MapRenderer({
|
||||||
resetHotCold()
|
resetHotCold()
|
||||||
}, [currentPrompt, 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
|
// Update context with controls state for GameInfoPanel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setControlsState({
|
setControlsState({
|
||||||
|
|
@ -4202,11 +4267,7 @@ export function MapRenderer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* North indicator - red triangle pointing up */}
|
{/* North indicator - red triangle pointing up */}
|
||||||
<polygon
|
<polygon points="16,1 14,5 18,5" fill="#ef4444" opacity={0.9} />
|
||||||
points="16,1 14,5 18,5"
|
|
||||||
fill="#ef4444"
|
|
||||||
opacity={0.9}
|
|
||||||
/>
|
|
||||||
</animated.g>
|
</animated.g>
|
||||||
</animated.svg>
|
</animated.svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4336,7 +4397,13 @@ export function MapRenderer({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{/* Center dot */}
|
{/* Center dot */}
|
||||||
<circle cx="20" cy="20" r="1.5" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
<circle
|
||||||
|
cx="20"
|
||||||
|
cy="20"
|
||||||
|
r="1.5"
|
||||||
|
fill={heatStyle.color}
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
{/* Counter-rotating group to keep N fixed pointing up */}
|
{/* Counter-rotating group to keep N fixed pointing up */}
|
||||||
<animated.g
|
<animated.g
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -4345,11 +4412,7 @@ export function MapRenderer({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* North indicator - red triangle pointing up */}
|
{/* North indicator - red triangle pointing up */}
|
||||||
<polygon
|
<polygon points="20,2 17.5,7 22.5,7" fill="#ef4444" opacity={0.9} />
|
||||||
points="20,2 17.5,7 22.5,7"
|
|
||||||
fill="#ef4444"
|
|
||||||
opacity={0.9}
|
|
||||||
/>
|
|
||||||
</animated.g>
|
</animated.g>
|
||||||
</animated.svg>
|
</animated.svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,99 @@
|
||||||
import { useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { useLocale } from 'next-intl'
|
import { useLocale } from 'next-intl'
|
||||||
import { getHints, hasHints, type HintMap } from '../messages'
|
import { getHints, hasHints, type HintMap } from '../messages'
|
||||||
import type { Locale } from '@/i18n/messages'
|
import type { Locale } from '@/i18n/messages'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get a randomly selected hint for a region in the current locale.
|
* Hook to get hints for a region with cycling support.
|
||||||
* The hint is selected once when the region changes and stays consistent
|
* Starts with a random hint, and can cycle to the next hint when user is struggling.
|
||||||
* for that region prompt. Different hints may appear on subsequent attempts
|
|
||||||
* at the same region.
|
|
||||||
*
|
*
|
||||||
* 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
|
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<string | null>(null)
|
const lastRegionRef = useRef<string | null>(null)
|
||||||
const selectedHintRef = useRef<string | null>(null)
|
// Track the starting index (randomized on region change)
|
||||||
|
const startIndexRef = useRef<number>(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
|
// Get all hints for this region
|
||||||
const hint = useMemo(() => {
|
const allHints = useMemo(() => {
|
||||||
if (!regionId) {
|
if (!regionId) return []
|
||||||
lastRegionRef.current = null
|
return getHints(locale, map, regionId) ?? []
|
||||||
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
|
|
||||||
}, [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<string | null>(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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue