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 03d9e17b..644a39ba 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 @@ -2,7 +2,8 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { css } from '@styled/css' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useSpring, animated, to } from '@react-spring/web' import { useViewerId } from '@/lib/arcade/game-sdk' import { useTheme } from '@/contexts/ThemeContext' import { @@ -25,6 +26,8 @@ import { const GIVE_UP_ANIMATION_DURATION = 2000 // Duration for the "attention grab" phase of the name display (ms) const NAME_ATTENTION_DURATION = 3000 +// React-spring config for smooth takeover transitions +const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 } // Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji) function getHotColdEmoji(type: FeedbackType | null | undefined): string { @@ -75,9 +78,6 @@ export function GameInfoPanel({ progress, onHintsUnlock, }: GameInfoPanelProps) { - // Get flag emoji for world map countries (not USA states) - const flagEmoji = - selectedMap === 'world' && currentRegionId ? getCountryFlagEmoji(currentRegionId) : '' const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' const { state, lastError, clearError, giveUp, controlsState } = useKnowYourWorld() @@ -133,6 +133,18 @@ export function GameInfoPanel({ return lastError }, [lastError, currentDifficultyLevel]) + // During give-up animation, show the given-up region's name instead of the next region + const displayRegionName = isGiveUpAnimating + ? state.giveUpReveal?.regionName ?? currentRegionName + : currentRegionName + const displayRegionId = isGiveUpAnimating + ? state.giveUpReveal?.regionId ?? currentRegionId + : currentRegionId + + // Get flag emoji for the displayed region (not necessarily the current prompt) + const displayFlagEmoji = + selectedMap === 'world' && displayRegionId ? getCountryFlagEmoji(displayRegionId) : '' + // Track if animation is in progress (local state based on timestamp) const [isAnimating, setIsAnimating] = useState(false) @@ -148,8 +160,89 @@ export function GameInfoPanel({ return getAssistanceLevel(state.assistanceLevel) }, [state.assistanceLevel]) - // Check if name confirmation is required + // Check if name confirmation is required (learning mode) const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0 + const isLearningMode = state.assistanceLevel === 'learning' + + // Ref to measure the takeover container (region name + instructions) + const takeoverContainerRef = useRef(null) + + // Calculate the safe scale factor and offsets for perfect centering + const [safeScale, setSafeScale] = useState(2.5) + const [centerXOffset, setCenterXOffset] = useState(0) + const [centerYOffset, setCenterYOffset] = useState(0) + + // Measure container and calculate safe scale + centering offsets when region changes + useLayoutEffect(() => { + if (!currentRegionName || !isLearningMode) return + + // Use requestAnimationFrame to ensure text has rendered + const rafId = requestAnimationFrame(() => { + if (takeoverContainerRef.current) { + const rect = takeoverContainerRef.current.getBoundingClientRect() + + // Calculate max scale that keeps element within viewport bounds + // Leave 40px padding on each side + const maxWidthScale = rect.width > 0 ? (window.innerWidth - 80) / rect.width : 2.5 + const maxHeightScale = rect.height > 0 ? (window.innerHeight - 80) / rect.height : 2.5 + // Use the smaller of width/height constraints, clamped between 1.5 and 3.5 + const calculatedScale = Math.min(maxWidthScale, maxHeightScale) + setSafeScale(Math.max(1.5, Math.min(3.5, calculatedScale))) + + // Calculate X offset to center horizontally on screen + const elementCenterX = rect.left + rect.width / 2 + const screenCenterX = window.innerWidth / 2 + setCenterXOffset(screenCenterX - elementCenterX) + + // Calculate Y offset to center vertically on screen + const elementCenterY = rect.top + rect.height / 2 + const screenCenterY = window.innerHeight / 2 + setCenterYOffset(screenCenterY - elementCenterY) + } + }) + + return () => cancelAnimationFrame(rafId) + }, [currentRegionName, isLearningMode]) + + // Calculate takeover progress based on letters typed (0 = full takeover, 1 = complete) + // Suppress takeover during give up animation to avoid visual conflict + const takeoverProgress = useMemo(() => { + // During give up animation, suppress takeover (progress = 1 means no takeover) + if (isGiveUpAnimating) return 1 + if (!isLearningMode || requiresNameConfirmation === 0) return 1 + return Math.min(1, confirmedLetterCount / requiresNameConfirmation) + }, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating]) + + // Memoize spring target values to avoid recalculating on every render + const springTargets = useMemo( + () => ({ + scale: safeScale - (safeScale - 1) * takeoverProgress, + x: centerXOffset * (1 - takeoverProgress), + y: centerYOffset * (1 - takeoverProgress), + }), + [safeScale, centerXOffset, centerYOffset, takeoverProgress] + ) + + // React-spring animation for takeover transition + const takeoverSpring = useSpring({ + ...springTargets, + config: TAKEOVER_ANIMATION_CONFIG, + }) + + // Memoize the transform interpolation to avoid recreating on every render + const transformStyle = useMemo( + () => ({ + transform: to( + [takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y], + (s, x, y) => `translate(${x}px, ${y}px) scale(${s})` + ), + }), + [takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y] + ) + + // Memoize whether we're in active takeover mode + const isInTakeover = isLearningMode && takeoverProgress < 1 + const showPulseAnimation = isLearningMode && takeoverProgress < 0.5 // Reset name confirmation when region changes useEffect(() => { @@ -323,9 +416,40 @@ export function GameInfoPanel({ 0% { transform: translateX(-50%) translateY(100%); opacity: 0; } 100% { transform: translateX(-50%) translateY(0); opacity: 1; } } + @keyframes takeoverPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.85; } + } `} + {/* Takeover backdrop scrim - blurs background but leaves nav accessible */} + {/* Always rendered but uses CSS transitions for smooth fade in/out */} +
+ {/* TOP-CENTER: Prompt Display - positioned below game nav (~150px) */} + {/* Background fills left-to-right as progress increases */}
+ {/* Remaining count - centered at top */} +
+ {totalRegions - foundCount} left +
{/* Header row with Find label and controls */}
- - - + âš™ī¸ + + + + + - - - {/* Auto-Show Hints toggle - only if hints are available */} - {shouldShowAutoHintToggle(assistanceConfig) && - (() => { - const isLocked = requiresNameConfirmation > 0 && !nameConfirmed - return ( - !isLocked && onAutoHintToggle()} - className={css({ - display: 'flex', - alignItems: 'center', - gap: '2', - padding: '2', - fontSize: 'xs', - cursor: isLocked ? 'not-allowed' : 'pointer', - rounded: 'md', - color: isLocked - ? isDark - ? 'gray.500' - : 'gray.400' - : isDark - ? 'gray.200' - : 'gray.700', - outline: 'none', - opacity: isLocked ? 0.6 : 1, - _hover: isLocked - ? {} - : { - bg: isDark ? 'gray.700' : 'gray.100', - }, - _focus: isLocked - ? {} - : { - bg: isDark ? 'gray.700' : 'gray.100', - }, - })} - > - {isLocked ? ( - 🔒 - ) : ( - - ✓ - - )} - - 💡 Auto-Show Hints - - - ) - })()} - - {/* Auto-speak toggle - only if speech supported AND hints available */} - {isSpeechSupported && - shouldShowAutoSpeakToggle(assistanceConfig) && - (() => { - const isLocked = requiresNameConfirmation > 0 && !nameConfirmed - return ( - !isLocked && onAutoSpeakToggle()} - className={css({ - display: 'flex', - alignItems: 'center', - gap: '2', - padding: '2', - fontSize: 'xs', - cursor: isLocked ? 'not-allowed' : 'pointer', - rounded: 'md', - color: isLocked - ? isDark - ? 'gray.500' - : 'gray.400' - : isDark - ? 'gray.200' - : 'gray.700', - outline: 'none', - opacity: isLocked ? 0.6 : 1, - _hover: isLocked - ? {} - : { - bg: isDark ? 'gray.700' : 'gray.100', - }, - _focus: isLocked - ? {} - : { - bg: isDark ? 'gray.700' : 'gray.100', - }, - })} - > - {isLocked ? ( - 🔒 - ) : ( - - ✓ - - )} - - 🔈 Auto Speak - - - ) - })()} - - {/* Hot/Cold toggle - only if available */} - {showHotCold && - (() => { - const isLocked = requiresNameConfirmation > 0 && !nameConfirmed - return ( - <> - + + + {/* Auto-Show Hints toggle - only if hints are available */} + {shouldShowAutoHintToggle(assistanceConfig) && + (() => { + const isLocked = requiresNameConfirmation > 0 && !nameConfirmed + return ( !isLocked && onHotColdToggle?.()} + onCheckedChange={() => !isLocked && onAutoHintToggle()} + className={css({ + display: 'flex', + alignItems: 'center', + gap: '2', + padding: '2', + fontSize: 'xs', + cursor: isLocked ? 'not-allowed' : 'pointer', + rounded: 'md', + color: isLocked + ? isDark + ? 'gray.500' + : 'gray.400' + : isDark + ? 'gray.200' + : 'gray.700', + outline: 'none', + opacity: isLocked ? 0.6 : 1, + _hover: isLocked + ? {} + : { + bg: isDark ? 'gray.700' : 'gray.100', + }, + _focus: isLocked + ? {} + : { + bg: isDark ? 'gray.700' : 'gray.100', + }, + })} + > + {isLocked ? ( + 🔒 + ) : ( + + ✓ + + )} + + 💡 Auto-Show Hints + + + ) + })()} + + {/* Auto-speak toggle - only if speech supported AND hints available */} + {isSpeechSupported && + shouldShowAutoSpeakToggle(assistanceConfig) && + (() => { + const isLocked = requiresNameConfirmation > 0 && !nameConfirmed + return ( + !isLocked && onAutoSpeakToggle()} className={css({ display: 'flex', alignItems: 'center', @@ -654,171 +735,252 @@ export function GameInfoPanel({ )} - {hotColdEnabled ? 'đŸ”Ĩ' : 'â„ī¸'} Hot/Cold + 🔈 Auto Speak - - ) - })()} - - - + ) + })()} + + {/* Hot/Cold toggle - only if available */} + {showHotCold && + (() => { + const isLocked = requiresNameConfirmation > 0 && !nameConfirmed + return ( + <> + + !isLocked && onHotColdToggle?.()} + className={css({ + display: 'flex', + alignItems: 'center', + gap: '2', + padding: '2', + fontSize: 'xs', + cursor: isLocked ? 'not-allowed' : 'pointer', + rounded: 'md', + color: isLocked + ? isDark + ? 'gray.500' + : 'gray.400' + : isDark + ? 'gray.200' + : 'gray.700', + outline: 'none', + opacity: isLocked ? 0.6 : 1, + _hover: isLocked + ? {} + : { + bg: isDark ? 'gray.700' : 'gray.100', + }, + _focus: isLocked + ? {} + : { + bg: isDark ? 'gray.700' : 'gray.100', + }, + })} + > + {isLocked ? ( + 🔒 + ) : ( + + ✓ + + )} + + {hotColdEnabled ? 'đŸ”Ĩ' : 'â„ī¸'} Hot/Cold + + + + ) + })()} + + + )}
-
- {flagEmoji && ( - - {flagEmoji} - - )} - {/* Inline letter highlighting for name confirmation */} - - {currentRegionName - ? currentRegionName.split('').map((char, index) => { - // Determine if this letter needs confirmation styling - const needsConfirmation = - requiresNameConfirmation > 0 && - !nameConfirmed && - index < requiresNameConfirmation - const isConfirmed = index < confirmedLetterCount - const isNextToConfirm = index === confirmedLetterCount && needsConfirmation - - return ( - - {char} - - ) - }) - : '...'} - -
- - {/* Type-to-unlock instruction OR inline hint */} - {requiresNameConfirmation > 0 && !nameConfirmed ? ( + {/* Region name display */}
- 👆 - Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''} + {displayFlagEmoji && ( + + {displayFlagEmoji} + + )} + {/* Inline letter highlighting for name confirmation */} + + {displayRegionName + ? displayRegionName.split('').map((char, index) => { + // Determine if this letter needs confirmation styling + // Disable during give-up animation (showing the given-up region) + const needsConfirmation = + !isGiveUpAnimating && + requiresNameConfirmation > 0 && + !nameConfirmed && + index < requiresNameConfirmation + const isConfirmed = index < confirmedLetterCount + const isNextToConfirm = index === confirmedLetterCount && needsConfirmation + + return ( + + {char} + + ) + }) + : '...'} +
- ) : ( - currentHint && ( + + {/* Type-to-unlock instruction - included in takeover container */} + {/* Hide during give-up animation since we're showing the given-up region */} + {!isGiveUpAnimating && requiresNameConfirmation > 0 && !nameConfirmed && (
- {/* Speaker button - subtle styling */} - {isSpeechSupported ? ( - - ) : ( - 💡 - )} - âŒ¨ī¸ + Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''} +
+ )} + + + {/* Inline hint - shown after name is confirmed (or always in non-learning modes) */} + {currentHint && (requiresNameConfirmation === 0 || nameConfirmed) && ( +
+ {/* Speaker button - subtle styling */} + {isSpeechSupported ? ( +
- ) + {isSpeaking ? 'âšī¸' : '🔈'} + + ) : ( + 💡 + )} + + {currentHint} + +
)} {/* Voting status for cooperative mode */} @@ -843,63 +1005,6 @@ export function GameInfoPanel({ )} - {/* TOP-LEFT: Progress Indicator - positioned below game nav */} -
-
- Progress -
-
- {foundCount}/{totalRegions} -
- {/* Mini progress bar */} -
-
-
-
- {/* BOTTOM-CENTER: Error Banner (toast-style) */} {lastError && (