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:
parent
c7c4e7cef3
commit
426a1e6868
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})()}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue