feat(know-your-world): add puzzle piece fly-to-map animation for learning mode

Add animated region silhouette that flies from takeover screen to map position
when user correctly identifies a region in learning mode:

- Add PuzzlePieceTarget interface with sourceRect for animation positioning
- Capture takeover region position with ref and getBoundingClientRect
- Use react-spring to animate from takeover to map position
- Preserve aspect ratio during takeover display
- Fix flash at animation end by checking isInTakeoverLocal
- Add debug logging for accent normalization

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-01 07:49:42 -06:00
parent dcc32c288f
commit 7c496525e9
4 changed files with 551 additions and 171 deletions

View File

@ -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<React.SetStateAction<CelebrationState | null>>
promptStartTime: React.MutableRefObject<number>
// Puzzle piece animation state (Learning mode only)
// When set, GameInfoPanel animates the region silhouette to the target position
puzzlePieceTarget: PuzzlePieceTarget | null
setPuzzlePieceTarget: React.Dispatch<React.SetStateAction<PuzzlePieceTarget | null>>
// Shared container ref for pointer lock button detection
sharedContainerRef: React.RefObject<HTMLDivElement>
}
@ -174,6 +207,9 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
const promptStartTime = useRef<number>(Date.now())
// Puzzle piece animation state (Learning mode only)
const [puzzlePieceTarget, setPuzzlePieceTarget] = useState<PuzzlePieceTarget | null>(null)
// Shared container ref for pointer lock button detection
const sharedContainerRef = useRef<HTMLDivElement>(null)
@ -597,6 +633,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
celebration,
setCelebration,
promptStartTime,
puzzlePieceTarget,
setPuzzlePieceTarget,
sharedContainerRef,
}}
>

View File

@ -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 }
}

View File

@ -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<HTMLDivElement>(null)
// Ref to the takeover region shape SVG for capturing source position
const takeoverRegionShapeRef = useRef<SVGSVGElement>(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({
`}</style>
{/* 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) */}
<div
data-element="takeover-overlay"
className={css({
@ -540,10 +683,10 @@ export function GameInfoPanel({
transition: 'opacity 0.3s ease-out',
})}
style={{
opacity: isInTakeoverLocal ? 1 : 0,
opacity: isInTakeoverLocal || isPuzzlePieceAnimating ? 1 : 0,
}}
>
{/* Backdrop scrim with blur */}
{/* Backdrop scrim with blur - fade out during puzzle piece animation */}
<div
data-element="takeover-scrim"
className={css({
@ -552,36 +695,105 @@ export function GameInfoPanel({
left: 0,
right: 0,
bottom: 0,
transition: 'opacity 0.3s ease-out',
})}
style={{
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
// Fade out scrim during puzzle piece animation
opacity: isPuzzlePieceAnimating ? 0 : 1,
}}
/>
{/* Region shape silhouette */}
{displayRegionShape && (
{/* Region shape silhouette - shown during takeover, until animation starts */}
{/* Must check isInTakeoverLocal to prevent flash when animation ends */}
{displayRegionShape && isInTakeoverLocal && !isPuzzlePieceAnimating && (() => {
// 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 (
<svg
ref={takeoverRegionShapeRef}
data-element="takeover-region-shape"
viewBox={displayRegionShape.viewBox}
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
className={css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
})}
style={{
position: 'fixed',
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 151,
pointerEvents: 'none',
}}
>
<path
d={displayRegionShape.path}
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'}
/>
</svg>
)
})()}
{/* Animated puzzle piece silhouette - flies from center to map position */}
{puzzlePieceShape && isPuzzlePieceAnimating && (
<animated.svg
data-element="puzzle-piece-silhouette"
viewBox={puzzlePieceShape.viewBox}
preserveAspectRatio="none"
style={{
position: 'fixed',
left: puzzlePieceSpring.x.to((x) => `${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',
}}
>
<path
d={puzzlePieceShape.path}
fill={isDark ? 'rgba(59, 130, 246, 0.8)' : 'rgba(59, 130, 246, 0.6)'}
stroke={isDark ? '#3b82f6' : '#2563eb'}
strokeWidth={2}
/>
</animated.svg>
)}
{/* Takeover text - CSS centered, only scale is animated */}
{/* Hidden during puzzle piece animation */}
{!isPuzzlePieceAnimating && (
<animated.div
data-element="takeover-content"
className={css({
@ -722,14 +934,16 @@ export function GameInfoPanel({
textAlign: 'center',
})}
>
Not your turn! Wait for {playerMetadata[currentPlayer]?.name || 'the other player'}
.
Not your turn! Wait for{' '}
{playerMetadata[currentPlayer]?.name || 'the other player'}.
</div>
)}
</animated.div>
)}
{/* 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

View File

@ -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
// 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