From 426a1e68687da1f4bbf84aa855004571892f60f8 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 27 Nov 2025 11:41:48 -0600 Subject: [PATCH] feat(know-your-world): speak country names in user's locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/GameInfoPanel.tsx | 199 +++++++++++++++++- .../components/MapRenderer.tsx | 171 ++++++++++----- .../know-your-world/hooks/useSpeakHint.ts | 61 ++++++ .../src/arcade-games/know-your-world/maps.ts | 6 +- .../know-your-world/utils/speechSynthesis.ts | 7 +- 5 files changed, 374 insertions(+), 70 deletions(-) diff --git a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx index e2f5e92f..f93a1932 100644 --- a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx @@ -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(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); } } `}
- {flagEmoji && {flagEmoji}} + {flagEmoji && ( + + {flagEmoji} + + )} {currentRegionName || '...'}
+ + {/* Name confirmation input - only show if required and not yet confirmed */} + {requiresNameConfirmation > 0 && !nameConfirmed && currentRegionName && ( +
+ 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', + }} + /> + + 🔒 Type to unlock hints + +
+ )} + + {/* Show confirmed state briefly */} + {requiresNameConfirmation > 0 && nameConfirmed && ( +
+ ✓ + Hints unlocked! +
+ )}
{/* Progress - compact */} 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 ddcf8d23..cfe43382 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 @@ -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(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 ? ( -
- {/* Crosshair - Vertical line */} + <>
- {/* Crosshair - Horizontal line */} -
-
+ > + {/* Crosshair - Vertical line */} +
+ {/* Crosshair - Horizontal line */} +
+
+ {/* Cursor region name label - shows what to find under the cursor */} + {currentRegionName && ( +
+ + Find + + + {currentRegionName} + + {currentFlagEmoji && {currentFlagEmoji}} +
+ )} + ) : null })()} diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts b/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts index 22a1db0c..2c9c3622 100644 --- a/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts +++ b/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts @@ -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, diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts index 6c7f83ff..8cd3b51b 100644 --- a/apps/web/src/arcade-games/know-your-world/maps.ts +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -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', diff --git a/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts b/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts index dddb9b67..94d44972 100644 --- a/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts +++ b/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts @@ -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 {