feat(know-your-world): speak country names in user's locale

Add speakWithRegionName function to ensure country names are pronounced
in the user's accent/locale for better learning, while hints can still
optionally use regional accent. Updated MapRenderer and GameInfoPanel
to use this new speech pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-27 11:41:48 -06:00
parent c7c4e7cef3
commit 426a1e6868
5 changed files with 374 additions and 70 deletions

View File

@ -1,14 +1,22 @@
'use client'
import { useCallback, useEffect, useState, useMemo } from 'react'
import { useCallback, useEffect, useState, useMemo, useRef } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import { useKnowYourWorld } from '../Provider'
import type { MapData } from '../types'
import { getCountryFlagEmoji, WORLD_MAP, USA_MAP, DEFAULT_DIFFICULTY_CONFIG } from '../maps'
import {
getCountryFlagEmoji,
WORLD_MAP,
USA_MAP,
DEFAULT_DIFFICULTY_CONFIG,
getAssistanceLevel,
} from '../maps'
// Animation duration in ms - must match MapRenderer
const GIVE_UP_ANIMATION_DURATION = 2000
// Duration for the "attention grab" phase of the name display (ms)
const NAME_ATTENTION_DURATION = 3000
interface GameInfoPanelProps {
mapData: MapData
@ -65,8 +73,63 @@ export function GameInfoPanel({
// Track if animation is in progress (local state based on timestamp)
const [isAnimating, setIsAnimating] = useState(false)
// Track if we're in the "attention grab" phase for the name display
const [isAttentionPhase, setIsAttentionPhase] = useState(false)
// Track name confirmation input
const [nameInput, setNameInput] = useState('')
const [nameConfirmed, setNameConfirmed] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
// Get assistance level config
const assistanceConfig = useMemo(() => {
return getAssistanceLevel(state.assistanceLevel)
}, [state.assistanceLevel])
// Check if name confirmation is required
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
// Reset name confirmation when region changes
useEffect(() => {
if (currentRegionId) {
setNameInput('')
setNameConfirmed(false)
setIsAttentionPhase(true)
// End attention phase after duration
const timeout = setTimeout(() => {
setIsAttentionPhase(false)
}, NAME_ATTENTION_DURATION)
// Focus the input after a short delay (let animation start first)
if (requiresNameConfirmation > 0) {
setTimeout(() => {
nameInputRef.current?.focus()
}, 500)
}
return () => clearTimeout(timeout)
}
}, [currentRegionId, requiresNameConfirmation])
// Check if name input matches required letters
useEffect(() => {
if (requiresNameConfirmation > 0 && currentRegionName && nameInput.length > 0) {
const requiredPart = currentRegionName.slice(0, requiresNameConfirmation).toLowerCase()
const inputPart = nameInput.toLowerCase()
if (inputPart === requiredPart) {
setNameConfirmed(true)
}
}
}, [nameInput, currentRegionName, requiresNameConfirmation])
// Determine if hints are available based on difficulty config
const hintsAvailable = useMemo(() => {
// If name confirmation is required but not done yet, hints are locked
if (requiresNameConfirmation > 0 && !nameConfirmed) {
return false
}
const hintsMode = currentDifficultyLevel?.hintsMode
if (hintsMode === 'none') return false
if (hintsMode === 'limited') {
@ -74,7 +137,7 @@ export function GameInfoPanel({
return (state.hintsUsed ?? 0) < limit
}
return hintsMode === 'onRequest'
}, [currentDifficultyLevel, state.hintsUsed])
}, [currentDifficultyLevel, state.hintsUsed, requiresNameConfirmation, nameConfirmed])
// Calculate remaining hints for limited mode
const remainingHints = useMemo(() => {
@ -189,10 +252,36 @@ export function GameInfoPanel({
0%, 100% { box-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
50% { box-shadow: 0 0 20px rgba(59, 130, 246, 0.6), 0 0 30px rgba(59, 130, 246, 0.3); }
}
@keyframes popIn {
0% { transform: scale(0.8); opacity: 0; }
@keyframes attentionGrab {
0% {
transform: scale(0.8);
opacity: 0;
}
10% {
transform: scale(1.25);
opacity: 1;
text-shadow: 0 0 20px rgba(59, 130, 246, 0.8), 0 0 40px rgba(59, 130, 246, 0.4);
}
60% {
transform: scale(1.2);
text-shadow: 0 0 15px rgba(59, 130, 246, 0.6), 0 0 30px rgba(59, 130, 246, 0.3);
}
100% {
transform: scale(1);
text-shadow: none;
}
}
@keyframes nameShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
@keyframes confirmPop {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
100% { transform: scale(1); }
}
`}</style>
<div
@ -208,8 +297,9 @@ export function GameInfoPanel({
</div>
<div
key={currentRegionId || 'empty'} // Re-trigger animation on change
data-element="region-name-display"
className={css({
fontSize: '2xl',
fontSize: isAttentionPhase ? '3xl' : '2xl',
fontWeight: 'bold',
color: isDark ? 'white' : 'blue.900',
overflow: 'hidden',
@ -219,15 +309,106 @@ export function GameInfoPanel({
alignItems: 'center',
justifyContent: 'center',
gap: '2',
transition: 'font-size 0.5s ease-out',
})}
style={{
animation: 'popIn 0.4s ease-out',
animation: `attentionGrab ${NAME_ATTENTION_DURATION}ms ease-out`,
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none',
}}
>
{flagEmoji && <span className={css({ fontSize: '2xl' })}>{flagEmoji}</span>}
{flagEmoji && (
<span className={css({ fontSize: isAttentionPhase ? '3xl' : '2xl' })}>
{flagEmoji}
</span>
)}
<span>{currentRegionName || '...'}</span>
</div>
{/* Name confirmation input - only show if required and not yet confirmed */}
{requiresNameConfirmation > 0 && !nameConfirmed && currentRegionName && (
<div
data-element="name-confirmation"
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
marginTop: '2',
})}
>
<input
ref={nameInputRef}
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder={`Type first ${requiresNameConfirmation} letters...`}
maxLength={requiresNameConfirmation}
className={css({
padding: '2',
fontSize: 'lg',
fontWeight: 'bold',
textAlign: 'center',
width: '120px',
bg: isDark ? 'gray.800' : 'white',
color: isDark ? 'white' : 'gray.900',
border: '2px solid',
borderColor:
nameInput.length === requiresNameConfirmation
? nameInput.toLowerCase() ===
currentRegionName.slice(0, requiresNameConfirmation).toLowerCase()
? 'green.500'
: 'red.500'
: isDark
? 'gray.600'
: 'gray.300',
rounded: 'md',
outline: 'none',
_focus: {
borderColor: 'blue.500',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.3)',
},
})}
style={{
animation:
nameInput.length === requiresNameConfirmation &&
nameInput.toLowerCase() !==
currentRegionName.slice(0, requiresNameConfirmation).toLowerCase()
? 'nameShake 0.4s ease-in-out'
: 'none',
}}
/>
<span
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
🔒 Type to unlock hints
</span>
</div>
)}
{/* Show confirmed state briefly */}
{requiresNameConfirmation > 0 && nameConfirmed && (
<div
data-element="name-confirmed"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1',
marginTop: '2',
fontSize: 'sm',
color: 'green.500',
fontWeight: 'semibold',
})}
style={{
animation: 'confirmPop 0.3s ease-out',
}}
>
<span></span>
<span>Hints unlocked!</span>
</div>
)}
</div>
{/* Progress - compact */}

View File

@ -19,6 +19,7 @@ import {
filterRegionsByContinent,
parseViewBox,
calculateFitCropViewBox,
getCountryFlagEmoji,
} from '../maps'
import type { ContinentId } from '../continents'
import {
@ -501,16 +502,15 @@ export function MapRenderer({
return region?.name ?? null
}, [currentPrompt, mapData.regions])
// Create full hint text with region name prefix for speech
const fullHintText = useMemo(() => {
if (!hintText) return null
if (!currentRegionName) return hintText
return `${currentRegionName}. ${hintText}`
}, [currentRegionName, hintText])
// Get flag emoji for cursor label (world map only)
const currentFlagEmoji = useMemo(() => {
if (selectedMap !== 'world' || !currentPrompt) return ''
return getCountryFlagEmoji(currentPrompt)
}, [selectedMap, currentPrompt])
// Speech synthesis for reading hints aloud
const {
speak: speakHint,
speakWithRegionName,
stop: stopSpeaking,
isSpeaking,
isSupported: isSpeechSupported,
@ -523,11 +523,11 @@ export function MapRenderer({
return localStorage.getItem('knowYourWorld.autoSpeakHint') === 'true'
})
// With accent setting persisted in localStorage (default true)
// With accent setting persisted in localStorage (default false - use user's locale for consistent pronunciation)
const [withAccent, setWithAccent] = useState(() => {
if (typeof window === 'undefined') return true
if (typeof window === 'undefined') return false
const stored = localStorage.getItem('knowYourWorld.withAccent')
return stored === null ? true : stored === 'true'
return stored === null ? false : stored === 'true'
})
// Auto-hint setting persisted in localStorage (auto-opens hint on region advance)
@ -599,10 +599,10 @@ export function MapRenderer({
const handleSpeakClick = useCallback(() => {
if (isSpeaking) {
stopSpeaking()
} else if (fullHintText) {
speakHint(fullHintText, withAccent)
} else if (currentRegionName) {
speakWithRegionName(currentRegionName, hintText, withAccent)
}
}, [isSpeaking, stopSpeaking, fullHintText, speakHint, withAccent])
}, [isSpeaking, stopSpeaking, currentRegionName, hintText, speakWithRegionName, withAccent])
const speakButton = usePointerLockButton({
id: 'speak-hint',
@ -726,10 +726,18 @@ export function MapRenderer({
const justOpened = showHintBubble && !prevShowHintBubbleRef.current
prevShowHintBubbleRef.current = showHintBubble
if (justOpened && autoSpeak && fullHintText && isSpeechSupported) {
speakHint(fullHintText, withAccent)
if (justOpened && autoSpeak && currentRegionName && isSpeechSupported) {
speakWithRegionName(currentRegionName, hintText, withAccent)
}
}, [showHintBubble, autoSpeak, fullHintText, isSpeechSupported, speakHint, withAccent])
}, [
showHintBubble,
autoSpeak,
currentRegionName,
hintText,
isSpeechSupported,
speakWithRegionName,
withAccent,
])
// Track previous prompt to detect region changes
const prevPromptRef = useRef<string | null>(null)
@ -755,13 +763,13 @@ export function MapRenderer({
setShowHintBubble(true)
// If region changed and both auto-hint and auto-speak are enabled, speak immediately
// This handles the case where the bubble was already open
if (isNewRegion && autoSpeakRef.current && fullHintText && isSpeechSupported) {
speakHint(fullHintText, withAccentRef.current)
if (isNewRegion && autoSpeakRef.current && currentRegionName && isSpeechSupported) {
speakWithRegionName(currentRegionName, hintText, withAccentRef.current)
}
} else {
setShowHintBubble(false)
}
}, [currentPrompt, hasHint, fullHintText, isSpeechSupported, speakHint])
}, [currentPrompt, hasHint, currentRegionName, hintText, isSpeechSupported, speakWithRegionName])
// Hot/cold audio feedback hook
// Only enabled if: 1) assistance level allows it, 2) user toggle is on, 3) not touch device
@ -2920,49 +2928,96 @@ export function MapRenderer({
{(() => {
// Debug logging removed - was flooding console
return pointerLocked && cursorPosition ? (
<div
data-element="custom-cursor"
style={{
position: 'absolute',
left: `${cursorPosition.x}px`,
top: `${cursorPosition.y}px`,
width: '20px',
height: '20px',
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 200,
transform: `translate(-50%, -50%) scale(${cursorSquish.x}, ${cursorSquish.y})`,
backgroundColor: 'transparent',
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.1s ease-out', // Smooth squish animation
}}
>
{/* Crosshair - Vertical line */}
<>
<div
data-element="custom-cursor"
style={{
position: 'absolute',
left: '50%',
top: '0',
width: '2px',
height: '100%',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateX(-50%)',
left: `${cursorPosition.x}px`,
top: `${cursorPosition.y}px`,
width: '20px',
height: '20px',
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
borderRadius: '50%',
pointerEvents: 'none',
zIndex: 200,
transform: `translate(-50%, -50%) scale(${cursorSquish.x}, ${cursorSquish.y})`,
backgroundColor: 'transparent',
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.1s ease-out', // Smooth squish animation
}}
/>
{/* Crosshair - Horizontal line */}
<div
style={{
position: 'absolute',
left: '0',
top: '50%',
width: '100%',
height: '2px',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateY(-50%)',
}}
/>
</div>
>
{/* Crosshair - Vertical line */}
<div
style={{
position: 'absolute',
left: '50%',
top: '0',
width: '2px',
height: '100%',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateX(-50%)',
}}
/>
{/* Crosshair - Horizontal line */}
<div
style={{
position: 'absolute',
left: '0',
top: '50%',
width: '100%',
height: '2px',
backgroundColor: isDark ? '#60a5fa' : '#3b82f6',
transform: 'translateY(-50%)',
}}
/>
</div>
{/* Cursor region name label - shows what to find under the cursor */}
{currentRegionName && (
<div
data-element="cursor-region-label"
style={{
position: 'absolute',
left: `${cursorPosition.x}px`,
top: `${cursorPosition.y + 18}px`,
transform: 'translateX(-50%)',
pointerEvents: 'none',
zIndex: 201,
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
backgroundColor: isDark ? 'rgba(30, 58, 138, 0.95)' : 'rgba(219, 234, 254, 0.95)',
border: `2px solid ${isDark ? '#60a5fa' : '#3b82f6'}`,
borderRadius: '6px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
whiteSpace: 'nowrap',
}}
>
<span
style={{
fontSize: '10px',
fontWeight: 'bold',
color: isDark ? '#93c5fd' : '#1e40af',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Find
</span>
<span
style={{
fontSize: '13px',
fontWeight: 'bold',
color: isDark ? 'white' : '#1e3a8a',
}}
>
{currentRegionName}
</span>
{currentFlagEmoji && <span style={{ fontSize: '14px' }}>{currentFlagEmoji}</span>}
</div>
)}
</>
) : null
})()}

View File

@ -121,6 +121,66 @@ export function useSpeakHint(map: string, regionId: string | null) {
[hasAccentOption, regionLang, userLang, speakWithLang]
)
// Speak region name (always in user's locale) followed by hint text (optionally with accent)
// This ensures kids hear the country name in their own accent for better learning
const speakWithRegionName = useCallback(
(regionName: string, hintText: string | null, withAccent: boolean = false) => {
if (!isSupported) return
// Cancel any ongoing speech
if (cancelRef.current) {
cancelRef.current()
}
setIsSpeaking(true)
// Always speak region name in user's locale
const { cancel: cancelName } = speakText(regionName, userLang, {
rate: 0.85,
onError: () => {
setIsSpeaking(false)
cancelRef.current = null
},
})
cancelRef.current = cancelName
// If there's hint text, queue it after the region name
if (hintText) {
const hintLang = withAccent && hasAccentOption ? regionLang : userLang
speakText(hintText, hintLang, {
rate: 0.85,
queue: true, // Add to queue, don't cancel
onEnd: () => {
setIsSpeaking(false)
cancelRef.current = null
},
onError: () => {
setIsSpeaking(false)
cancelRef.current = null
},
})
} else {
// No hint text, just speaking the name - need onEnd handler
// Re-speak with onEnd handler (the first utterance will be replaced)
speechSynthesis.cancel()
speakText(regionName, userLang, {
rate: 0.85,
onStart: () => setIsSpeaking(true),
onEnd: () => {
setIsSpeaking(false)
cancelRef.current = null
},
onError: () => {
setIsSpeaking(false)
cancelRef.current = null
},
})
}
},
[isSupported, userLang, regionLang, hasAccentOption]
)
const stop = useCallback(() => {
if (cancelRef.current) {
cancelRef.current()
@ -131,6 +191,7 @@ export function useSpeakHint(map: string, regionId: string | null) {
return {
speak,
speakWithRegionName,
stop,
isSpeaking,
isSupported,

View File

@ -154,6 +154,8 @@ export interface AssistanceLevelConfig {
struggleHintEnabled: boolean
giveUpMode: GiveUpMode
wrongClickShowsName: boolean
// Name reinforcement
nameConfirmationLetters?: number // If set, require typing first N letters before hints unlock
}
/**
@ -164,13 +166,15 @@ export const ASSISTANCE_LEVELS: AssistanceLevelConfig[] = [
id: 'guided',
label: 'Guided',
emoji: '🎓',
description: 'Maximum help - hot/cold feedback, auto-hints, shows names on wrong clicks',
description:
'Maximum help - type name to unlock hints, hot/cold feedback, shows names on wrong clicks',
hotColdEnabled: true,
hintsMode: 'onRequest',
autoHintDefault: true,
struggleHintEnabled: true,
giveUpMode: 'reaskSoon',
wrongClickShowsName: true,
nameConfirmationLetters: 3, // Must type first 3 letters to unlock hints
},
{
id: 'helpful',

View File

@ -400,6 +400,7 @@ export function speakText(
onStart?: () => void
onEnd?: () => void
onError?: (error: SpeechSynthesisErrorEvent) => void
queue?: boolean // If true, don't cancel ongoing speech - add to queue
}
): { cancel: () => void } {
const voices = speechSynthesis.getVoices()
@ -430,8 +431,10 @@ export function speakText(
utterance.onerror = options.onError
}
// Cancel any ongoing speech and start new
speechSynthesis.cancel()
// Cancel any ongoing speech unless queuing
if (!options?.queue) {
speechSynthesis.cancel()
}
speechSynthesis.speak(utterance)
return {