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 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 */}
|
||||
<polygon
|
||||
points="16,1 14,5 18,5"
|
||||
fill="#ef4444"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<polygon points="16,1 14,5 18,5" fill="#ef4444" opacity={0.9} />
|
||||
</animated.g>
|
||||
</animated.svg>
|
||||
</div>
|
||||
|
|
@ -4336,7 +4397,13 @@ export function MapRenderer({
|
|||
)
|
||||
})}
|
||||
{/* 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 */}
|
||||
<animated.g
|
||||
style={{
|
||||
|
|
@ -4345,11 +4412,7 @@ export function MapRenderer({
|
|||
}}
|
||||
>
|
||||
{/* North indicator - red triangle pointing up */}
|
||||
<polygon
|
||||
points="20,2 17.5,7 22.5,7"
|
||||
fill="#ef4444"
|
||||
opacity={0.9}
|
||||
/>
|
||||
<polygon points="20,2 17.5,7 22.5,7" fill="#ef4444" opacity={0.9} />
|
||||
</animated.g>
|
||||
</animated.svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<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