diff --git a/apps/web/src/arcade-games/know-your-world/Provider.tsx b/apps/web/src/arcade-games/know-your-world/Provider.tsx index 52452355..c87e8d53 100644 --- a/apps/web/src/arcade-games/know-your-world/Provider.tsx +++ b/apps/web/src/arcade-games/know-your-world/Provider.tsx @@ -60,6 +60,34 @@ export interface CelebrationState { startTime: number } +// Puzzle piece animation target (Learning mode only) +// Contains the screen position where the region silhouette should animate to +export interface PuzzlePieceTarget { + regionId: string + regionName: string + celebrationType: CelebrationType + // Target screen position (top-left of where the region appears on the map) + x: number + y: number + // Target screen dimensions + width: number + height: number + // SVG coordinate bounding box (from getBBox()) for correct viewBox + svgBBox: { + x: number + y: number + width: number + height: number + } + // Source screen position (from takeover screen) - populated by GameInfoPanel + sourceRect?: { + x: number + y: number + width: number + height: number + } +} + const defaultControlsState: ControlsState = { isPointerLocked: false, fakeCursorPosition: null, @@ -142,6 +170,11 @@ interface KnowYourWorldContextValue { setCelebration: React.Dispatch> promptStartTime: React.MutableRefObject + // Puzzle piece animation state (Learning mode only) + // When set, GameInfoPanel animates the region silhouette to the target position + puzzlePieceTarget: PuzzlePieceTarget | null + setPuzzlePieceTarget: React.Dispatch> + // Shared container ref for pointer lock button detection sharedContainerRef: React.RefObject } @@ -174,6 +207,9 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode const [celebration, setCelebration] = useState(null) const promptStartTime = useRef(Date.now()) + // Puzzle piece animation state (Learning mode only) + const [puzzlePieceTarget, setPuzzlePieceTarget] = useState(null) + // Shared container ref for pointer lock button detection const sharedContainerRef = useRef(null) @@ -597,6 +633,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode celebration, setCelebration, promptStartTime, + puzzlePieceTarget, + setPuzzlePieceTarget, sharedContainerRef, }} > diff --git a/apps/web/src/arcade-games/know-your-world/Validator.ts b/apps/web/src/arcade-games/know-your-world/Validator.ts index 610320b3..fdad8995 100644 --- a/apps/web/src/arcade-games/know-your-world/Validator.ts +++ b/apps/web/src/arcade-games/know-your-world/Validator.ts @@ -29,6 +29,36 @@ export function getNthNonSpaceLetter( return null } +/** + * Get Unicode code points for a string (for debugging) + */ +function getCodePoints(str: string): string { + return [...str].map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`).join(' ') +} + +/** + * Normalize accented characters to their base ASCII letters. + * e.g., 'é' → 'e', 'ñ' → 'n', 'ü' → 'u', 'ç' → 'c', 'ô' → 'o' + * Uses Unicode NFD normalization to decompose characters, then strips diacritical marks. + */ +function normalizeToBaseLetter(char: string): string { + const nfd = char.normalize('NFD') + const stripped = nfd.replace(/[\u0300-\u036f]/g, '') + const result = stripped.toLowerCase() + // Debug logging for accent normalization + if (char !== result) { + console.log('[Validator] normalizeToBaseLetter:', { + input: char, + inputCodePoints: getCodePoints(char), + afterNFD: nfd, + nfdCodePoints: getCodePoints(nfd), + afterStrip: stripped, + result, + }) + } + return result +} + /** * Lazy-load map functions to avoid importing ES modules at module init time * This is critical for server-side usage where ES modules can't be required @@ -671,9 +701,24 @@ export class KnowYourWorldValidator return { valid: false, error: 'Letter index out of range' } } - // Check if the letter matches - if (letter.toLowerCase() !== letterInfo.char.toLowerCase()) { + // Check if the letter matches (normalize accents so 'o' matches 'ô', etc.) + const normalizedInput = normalizeToBaseLetter(letter) + const normalizedExpected = normalizeToBaseLetter(letterInfo.char) + + console.log('[Validator] confirmLetter:', { + inputLetter: letter, + normalizedInput, + expectedChar: letterInfo.char, + normalizedExpected, + match: normalizedInput === normalizedExpected, + regionName, + letterIndex, + currentProgress, + }) + + if (normalizedInput !== normalizedExpected) { // Wrong letter - don't advance progress (but move is still valid, just ignored) + console.log('[Validator] Letter mismatch - not advancing progress') return { valid: true, newState: state } } 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 fa0de2ca..d8ccbae0 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 @@ -33,16 +33,34 @@ const NAME_ATTENTION_DURATION = 3000 // React-spring config for smooth takeover transitions const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 } +/** + * Get Unicode code points for a string (for debugging) + */ +function getCodePoints(str: string): string { + return [...str].map((c) => `U+${c.codePointAt(0)?.toString(16).toUpperCase().padStart(4, '0')}`).join(' ') +} + /** * Normalize accented characters to their base ASCII letters. * e.g., 'é' → 'e', 'ñ' → 'n', 'ü' → 'u', 'ç' → 'c' * Uses Unicode NFD normalization to decompose characters, then strips diacritical marks. */ function normalizeToBaseLetter(char: string): string { - return char - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() + const nfd = char.normalize('NFD') + const stripped = nfd.replace(/[\u0300-\u036f]/g, '') + const result = stripped.toLowerCase() + // Debug logging for accent normalization + if (char !== result) { + console.log('[Client] normalizeToBaseLetter:', { + input: char, + inputCodePoints: getCodePoints(char), + afterNFD: nfd, + nfdCodePoints: getCodePoints(nfd), + afterStrip: stripped, + result, + }) + } + return result } // Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji) @@ -96,8 +114,18 @@ export function GameInfoPanel({ }: GameInfoPanelProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' - const { state, lastError, clearError, giveUp, confirmLetter, controlsState, setIsInTakeover } = - useKnowYourWorld() + const { + state, + lastError, + clearError, + giveUp, + confirmLetter, + controlsState, + setIsInTakeover, + puzzlePieceTarget, + setPuzzlePieceTarget, + setCelebration, + } = useKnowYourWorld() // Destructure controls state from context const { @@ -192,16 +220,16 @@ export function GameInfoPanel({ const displayFlagEmoji = selectedMap === 'world' && displayRegionId ? getCountryFlagEmoji(displayRegionId) : '' - // Get the region's SVG path for the takeover shape display + // Get the region's SVG path for the takeover shape display (no padding to match animation) const displayRegionShape = useMemo(() => { if (!displayRegionId) return null const region = mapData.regions.find((r) => r.id === displayRegionId) if (!region?.path) return null - // Calculate bounding box with padding for the viewBox + // Calculate bounding box WITHOUT padding - must match puzzlePieceTarget.svgBBox approach + // so part 1 (typing) and part 2 (animation) show the region at the same position const bbox = calculateBoundingBox([region.path]) - const padding = Math.max(bbox.width, bbox.height) * 0.1 // 10% padding - const viewBox = `${bbox.minX - padding} ${bbox.minY - padding} ${bbox.width + padding * 2} ${bbox.height + padding * 2}` + const viewBox = `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}` return { path: region.path, @@ -209,6 +237,23 @@ export function GameInfoPanel({ } }, [displayRegionId, mapData.regions]) + // Get the region's SVG path for puzzle piece animation + // Uses the exact SVG bounding box from getBBox() passed in the target + const puzzlePieceShape = useMemo(() => { + if (!puzzlePieceTarget) return null + const region = mapData.regions.find((r) => r.id === puzzlePieceTarget.regionId) + if (!region?.path) return null + + // Use the exact SVG bounding box from getBBox() - this is pixel-perfect + const { x, y, width, height } = puzzlePieceTarget.svgBBox + const viewBox = `${x} ${y} ${width} ${height}` + + return { + path: region.path, + viewBox, + } + }, [puzzlePieceTarget, mapData.regions]) + // Track if animation is in progress (local state based on timestamp) const [isAnimating, setIsAnimating] = useState(false) @@ -235,6 +280,9 @@ export function GameInfoPanel({ // Ref to measure the takeover container (region name + instructions) const takeoverContainerRef = useRef(null) + // Ref to the takeover region shape SVG for capturing source position + const takeoverRegionShapeRef = useRef(null) + // Calculate the safe scale factor based on viewport size const [safeScale, setSafeScale] = useState(2.5) @@ -296,14 +344,69 @@ export function GameInfoPanel({ config: TAKEOVER_ANIMATION_CONFIG, }) + // Puzzle piece animation spring - animates from takeover position to map position + const puzzlePieceSpring = useSpring({ + // Only animate when we have both target and source (sourceRect) + to: puzzlePieceTarget?.sourceRect + ? { + // Target: actual screen position of region on map (top-left coords) + x: puzzlePieceTarget.x, + y: puzzlePieceTarget.y, + width: puzzlePieceTarget.width, + height: puzzlePieceTarget.height, + opacity: 1, // Keep visible during animation + } + : { + // Default: centered on screen (fallback) + x: typeof window !== 'undefined' ? (window.innerWidth - 400) / 2 : 200, + y: typeof window !== 'undefined' ? (window.innerHeight - 400) / 2 : 100, + width: 400, + height: 400, + opacity: 1, + }, + from: puzzlePieceTarget?.sourceRect + ? { + // Start: actual position from takeover screen + x: puzzlePieceTarget.sourceRect.x, + y: puzzlePieceTarget.sourceRect.y, + width: puzzlePieceTarget.sourceRect.width, + height: puzzlePieceTarget.sourceRect.height, + opacity: 1, + } + : undefined, + reset: !!puzzlePieceTarget?.sourceRect, + config: { tension: 120, friction: 18 }, + onRest: () => { + if (puzzlePieceTarget) { + // Animation complete - start celebration + setCelebration({ + regionId: puzzlePieceTarget.regionId, + regionName: puzzlePieceTarget.regionName, + type: puzzlePieceTarget.celebrationType, + startTime: Date.now(), + }) + setPuzzlePieceTarget(null) + } + }, + }) + + // Whether we're in puzzle piece animation mode (has sourceRect means animation is active) + const isPuzzlePieceAnimating = puzzlePieceTarget !== null && puzzlePieceTarget.sourceRect !== undefined + + // Whether we're in the "fade back in" phase (target set, waiting for sourceRect capture) + const isFadingBackIn = puzzlePieceTarget !== null && puzzlePieceTarget.sourceRect === undefined + // Memoize whether we're in active takeover mode - const isInTakeoverLocal = isLearningMode && takeoverProgress < 1 + // Takeover visible: during typing OR when fading back in for animation OR during animation + const isInTakeoverLocal = + isLearningMode && (takeoverProgress < 1 || isFadingBackIn || isPuzzlePieceAnimating) const showPulseAnimation = isLearningMode && takeoverProgress < 0.5 // Sync takeover state to context (so MapRenderer can suppress hot/cold feedback) + // Also consider puzzle piece animation as a takeover state useEffect(() => { - setIsInTakeover(isInTakeoverLocal) - }, [isInTakeoverLocal, setIsInTakeover]) + setIsInTakeover(isInTakeoverLocal || isPuzzlePieceAnimating) + }, [isInTakeoverLocal, isPuzzlePieceAnimating, setIsInTakeover]) // Reset local UI state when region changes // Note: nameConfirmationProgress is reset on the server when prompt changes @@ -344,6 +447,37 @@ export function GameInfoPanel({ optimisticLetterCountRef.current = 0 }, [currentRegionName]) + // Capture source rect from takeover region shape when puzzlePieceTarget is set + // This provides the starting position for the fly-to-map animation + // Add a delay so the takeover is visible before the animation starts + useEffect(() => { + // Only capture if target is set but doesn't have sourceRect yet + if (puzzlePieceTarget && !puzzlePieceTarget.sourceRect) { + // Brief delay to let the takeover fade back in and be visible + const timeoutId = setTimeout(() => { + if (takeoverRegionShapeRef.current) { + const sourceRect = takeoverRegionShapeRef.current.getBoundingClientRect() + // Update the target with the source rect + setPuzzlePieceTarget((prev) => + prev && !prev.sourceRect + ? { + ...prev, + sourceRect: { + x: sourceRect.left, + y: sourceRect.top, + width: sourceRect.width, + height: sourceRect.height, + }, + } + : prev + ) + } + }, 400) // 400ms delay to show takeover before animation + + return () => clearTimeout(timeoutId) + } + }, [puzzlePieceTarget, setPuzzlePieceTarget]) + // Listen for keypresses to confirm letters (only when name confirmation is required) // Dispatches to shared state so all multiplayer sessions see the same progress useEffect(() => { @@ -388,6 +522,15 @@ export function GameInfoPanel({ // so users can type region names like "Côte d'Ivoire" or "São Tomé" with a regular keyboard const expectedLetter = normalizeToBaseLetter(letterInfo.char) + console.log('[LetterConfirm] Keyboard input:', { + pressedLetter, + letterInfo, + expectedLetter, + match: pressedLetter === expectedLetter, + regionName: currentRegionName, + letterIndex: nextLetterIndex, + }) + if (pressedLetter === expectedLetter) { // Optimistically advance count before server responds optimisticLetterCountRef.current = nextLetterIndex + 1 @@ -525,7 +668,7 @@ export function GameInfoPanel({ `} {/* Takeover overlay - contains scrim backdrop, region shape, and takeover text */} - {/* All children share the same stacking context */} + {/* Also shows during puzzle piece animation (silhouette only) */}
- {/* Backdrop scrim with blur */} + {/* Backdrop scrim with blur - fade out during puzzle piece animation */}
- {/* Region shape silhouette */} - {displayRegionShape && ( - { + // Parse viewBox to get aspect ratio + const viewBox = puzzlePieceTarget?.svgBBox + ? `${puzzlePieceTarget.svgBBox.x} ${puzzlePieceTarget.svgBBox.y} ${puzzlePieceTarget.svgBBox.width} ${puzzlePieceTarget.svgBBox.height}` + : displayRegionShape.viewBox + + // Extract width/height from viewBox for aspect ratio calculation + const viewBoxParts = viewBox.split(' ').map(Number) + const viewBoxWidth = viewBoxParts[2] || 1 + const viewBoxHeight = viewBoxParts[3] || 1 + const aspectRatio = viewBoxWidth / viewBoxHeight + + // Calculate container size that fits within 60% of viewport while preserving aspect ratio + const maxSize = typeof window !== 'undefined' + ? Math.min(window.innerWidth, window.innerHeight) * 0.6 + : 400 + + let width: number + let height: number + if (aspectRatio > 1) { + // Wider than tall + width = maxSize + height = maxSize / aspectRatio + } else { + // Taller than wide + height = maxSize + width = maxSize * aspectRatio + } + + // Center on screen + const x = typeof window !== 'undefined' ? (window.innerWidth - width) / 2 : 200 + const y = typeof window !== 'undefined' ? (window.innerHeight - height) / 2 : 100 + + return ( + + + + ) + })()} + + {/* Animated puzzle piece silhouette - flies from center to map position */} + {puzzlePieceShape && isPuzzlePieceAnimating && ( + `${x}px`), + top: puzzlePieceSpring.y.to((y) => `${y}px`), + width: puzzlePieceSpring.width.to((w) => `${w}px`), + height: puzzlePieceSpring.height.to((h) => `${h}px`), + opacity: puzzlePieceSpring.opacity, + zIndex: 9000, + pointerEvents: 'none', + }} > - + )} {/* Takeover text - CSS centered, only scale is animated */} - `translate(-50%, -50%) scale(${s})`), - animation: showPulseAnimation ? 'takeoverPulse 0.8s ease-in-out infinite' : 'none', - }} - > - {/* Region name display */} - - - {/* Type-to-unlock instruction */} - {!isGiveUpAnimating && requiresNameConfirmation > 0 && !nameConfirmed && ( + {/* Region name display */}
- {/* In turn-based mode, show current player's emoji to indicate whose turn it is */} - {gameMode === 'turn-based' && currentPlayerEmoji ? ( - {currentPlayerEmoji} - ) : ( - ⌨️ + {displayFlagEmoji && ( + + {displayFlagEmoji} + )} - {gameMode === 'turn-based' && !isMyTurn - ? `Waiting for ${playerMetadata[currentPlayer]?.name || 'player'} to type...` - : `Type the underlined letter${requiresNameConfirmation > 1 ? 's' : ''}`} + {displayRegionName + ? (() => { + // Track non-space letter index as we iterate + let nonSpaceIndex = 0 + return displayRegionName.split('').map((char, index) => { + const isSpace = char === ' ' + const currentNonSpaceIndex = isSpace ? -1 : nonSpaceIndex + + // Increment non-space counter AFTER getting current index + if (!isSpace) { + nonSpaceIndex++ + } + + // Spaces are always shown as confirmed (not underlined, full opacity) + if (isSpace) { + return ( + + {char} + + ) + } + + // For letters, check confirmation status using non-space index + const needsConfirmation = + !isGiveUpAnimating && + requiresNameConfirmation > 0 && + !nameConfirmed && + currentNonSpaceIndex < requiresNameConfirmation + const isConfirmed = currentNonSpaceIndex < confirmedLetterCount + const isNextToConfirm = + currentNonSpaceIndex === confirmedLetterCount && needsConfirmation + + return ( + + {char} + + ) + }) + })() + : '...'}
- )} - {/* "Not your turn" notice */} - {showNotYourTurn && ( -
- ⏳ Not your turn! Wait for {playerMetadata[currentPlayer]?.name || 'the other player'} - . -
- )} -
+ {/* Type-to-unlock instruction */} + {!isGiveUpAnimating && requiresNameConfirmation > 0 && !nameConfirmed && ( +
+ {/* In turn-based mode, show current player's emoji to indicate whose turn it is */} + {gameMode === 'turn-based' && currentPlayerEmoji ? ( + {currentPlayerEmoji} + ) : ( + ⌨️ + )} + + {gameMode === 'turn-based' && !isMyTurn + ? `Waiting for ${playerMetadata[currentPlayer]?.name || 'player'} to type...` + : `Type the underlined letter${requiresNameConfirmation > 1 ? 's' : ''}`} + +
+ )} + + {/* "Not your turn" notice */} + {showNotYourTurn && ( +
+ ⏳ Not your turn! Wait for{' '} + {playerMetadata[currentPlayer]?.name || 'the other player'}. +
+ )} + + )} {/* On-screen keyboard for mobile/touch devices - OUTSIDE animated container so it doesn't scale */} {!isGiveUpAnimating && + !isPuzzlePieceAnimating && requiresNameConfirmation > 0 && !nameConfirmed && currentRegionName && ( @@ -775,6 +989,15 @@ export function GameInfoPanel({ const expectedLetter = normalizeToBaseLetter(letterInfo.char) const pressedLetter = letter.toLowerCase() + console.log('[LetterConfirm] Virtual keyboard:', { + pressedLetter, + letterInfo, + expectedLetter, + match: pressedLetter === expectedLetter, + regionName: currentRegionName, + letterIndex: nextLetterIndex, + }) + if (pressedLetter === expectedLetter) { // Optimistically advance count before server responds optimisticLetterCountRef.current = nextLetterIndex + 1 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 247cb09d..e1e4ff47 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 @@ -367,6 +367,8 @@ export function MapRenderer({ celebration, setCelebration, promptStartTime, + puzzlePieceTarget, + setPuzzlePieceTarget, } = useKnowYourWorld() // Extract force tuning parameters with defaults const { @@ -1320,31 +1322,103 @@ export function MapRenderer({ // Wrapper function to intercept clicks and trigger celebration for correct regions const handleRegionClickWithCelebration = useCallback( (regionId: string, regionName: string) => { - // If we're already celebrating, ignore clicks - if (celebration) return + // If we're already celebrating or puzzle piece animating, ignore clicks + if (celebration || puzzlePieceTarget) return // Check if this is the correct region if (regionId === currentPrompt) { - // Correct! Start celebration + // Correct! Calculate celebration type const metrics = getSearchMetrics(promptStartTime.current) const celebrationType = classifyCelebration(metrics) // Store pending click for after celebration pendingCelebrationClick.current = { regionId, regionName } - // Start celebration - setCelebration({ - regionId, - regionName, - type: celebrationType, - startTime: Date.now(), - }) + // In Learning mode, show puzzle piece animation first + if (assistanceLevel === 'learning' && svgRef.current) { + // Query the actual DOM element to get its bounding boxes + const pathElement = svgRef.current.querySelector(`path[data-region-id="${regionId}"]`) + if (pathElement && pathElement instanceof SVGGraphicsElement) { + // Get the actual screen bounding rect of the rendered path + const pathRect = pathElement.getBoundingClientRect() + // Get the SVG coordinate bounding box (for viewBox) + const svgBBox = pathElement.getBBox() + + console.log('[PuzzlePiece] Direct DOM measurement:', { + regionId, + screenRect: { + left: pathRect.left, + top: pathRect.top, + width: pathRect.width, + height: pathRect.height, + }, + svgBBox: { + x: svgBBox.x, + y: svgBBox.y, + width: svgBBox.width, + height: svgBBox.height, + }, + }) + + setPuzzlePieceTarget({ + regionId, + regionName, + celebrationType, + // Target is the actual screen rect of the region on the map + x: pathRect.left, + y: pathRect.top, + width: pathRect.width, + height: pathRect.height, + // SVG coordinate bounding box for correct viewBox + svgBBox: { + x: svgBBox.x, + y: svgBBox.y, + width: svgBBox.width, + height: svgBBox.height, + }, + }) + } else { + // Fallback: start celebration immediately if path not found + setCelebration({ + regionId, + regionName, + type: celebrationType, + startTime: Date.now(), + }) + } + } else if (assistanceLevel === 'learning') { + // Learning mode but refs not ready - start celebration immediately + setCelebration({ + regionId, + regionName, + type: celebrationType, + startTime: Date.now(), + }) + } else { + // Other modes: Start celebration immediately + setCelebration({ + regionId, + regionName, + type: celebrationType, + startTime: Date.now(), + }) + } } else { // Wrong region - handle immediately onRegionClick(regionId, regionName) } }, - [celebration, currentPrompt, getSearchMetrics, promptStartTime, setCelebration, onRegionClick] + [ + celebration, + puzzlePieceTarget, + currentPrompt, + getSearchMetrics, + promptStartTime, + setCelebration, + setPuzzlePieceTarget, + onRegionClick, + assistanceLevel, + ] ) // Get center of celebrating region for confetti origin