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:
parent
dcc32c288f
commit
7c496525e9
|
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue