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
|
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 = {
|
const defaultControlsState: ControlsState = {
|
||||||
isPointerLocked: false,
|
isPointerLocked: false,
|
||||||
fakeCursorPosition: null,
|
fakeCursorPosition: null,
|
||||||
|
|
@ -142,6 +170,11 @@ interface KnowYourWorldContextValue {
|
||||||
setCelebration: React.Dispatch<React.SetStateAction<CelebrationState | null>>
|
setCelebration: React.Dispatch<React.SetStateAction<CelebrationState | null>>
|
||||||
promptStartTime: React.MutableRefObject<number>
|
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
|
// Shared container ref for pointer lock button detection
|
||||||
sharedContainerRef: React.RefObject<HTMLDivElement>
|
sharedContainerRef: React.RefObject<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +207,9 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
|
const [celebration, setCelebration] = useState<CelebrationState | null>(null)
|
||||||
const promptStartTime = useRef<number>(Date.now())
|
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
|
// Shared container ref for pointer lock button detection
|
||||||
const sharedContainerRef = useRef<HTMLDivElement>(null)
|
const sharedContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
|
@ -597,6 +633,8 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
|
||||||
celebration,
|
celebration,
|
||||||
setCelebration,
|
setCelebration,
|
||||||
promptStartTime,
|
promptStartTime,
|
||||||
|
puzzlePieceTarget,
|
||||||
|
setPuzzlePieceTarget,
|
||||||
sharedContainerRef,
|
sharedContainerRef,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,36 @@ export function getNthNonSpaceLetter(
|
||||||
return null
|
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
|
* 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
|
* 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' }
|
return { valid: false, error: 'Letter index out of range' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the letter matches
|
// Check if the letter matches (normalize accents so 'o' matches 'ô', etc.)
|
||||||
if (letter.toLowerCase() !== letterInfo.char.toLowerCase()) {
|
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)
|
// 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 }
|
return { valid: true, newState: state }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,34 @@ const NAME_ATTENTION_DURATION = 3000
|
||||||
// React-spring config for smooth takeover transitions
|
// React-spring config for smooth takeover transitions
|
||||||
const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 }
|
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.
|
* Normalize accented characters to their base ASCII letters.
|
||||||
* e.g., 'é' → 'e', 'ñ' → 'n', 'ü' → 'u', 'ç' → 'c'
|
* e.g., 'é' → 'e', 'ñ' → 'n', 'ü' → 'u', 'ç' → 'c'
|
||||||
* Uses Unicode NFD normalization to decompose characters, then strips diacritical marks.
|
* Uses Unicode NFD normalization to decompose characters, then strips diacritical marks.
|
||||||
*/
|
*/
|
||||||
function normalizeToBaseLetter(char: string): string {
|
function normalizeToBaseLetter(char: string): string {
|
||||||
return char
|
const nfd = char.normalize('NFD')
|
||||||
.normalize('NFD')
|
const stripped = nfd.replace(/[\u0300-\u036f]/g, '')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
const result = stripped.toLowerCase()
|
||||||
.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)
|
// Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji)
|
||||||
|
|
@ -96,8 +114,18 @@ export function GameInfoPanel({
|
||||||
}: GameInfoPanelProps) {
|
}: GameInfoPanelProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
const { state, lastError, clearError, giveUp, confirmLetter, controlsState, setIsInTakeover } =
|
const {
|
||||||
useKnowYourWorld()
|
state,
|
||||||
|
lastError,
|
||||||
|
clearError,
|
||||||
|
giveUp,
|
||||||
|
confirmLetter,
|
||||||
|
controlsState,
|
||||||
|
setIsInTakeover,
|
||||||
|
puzzlePieceTarget,
|
||||||
|
setPuzzlePieceTarget,
|
||||||
|
setCelebration,
|
||||||
|
} = useKnowYourWorld()
|
||||||
|
|
||||||
// Destructure controls state from context
|
// Destructure controls state from context
|
||||||
const {
|
const {
|
||||||
|
|
@ -192,16 +220,16 @@ export function GameInfoPanel({
|
||||||
const displayFlagEmoji =
|
const displayFlagEmoji =
|
||||||
selectedMap === 'world' && displayRegionId ? getCountryFlagEmoji(displayRegionId) : ''
|
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(() => {
|
const displayRegionShape = useMemo(() => {
|
||||||
if (!displayRegionId) return null
|
if (!displayRegionId) return null
|
||||||
const region = mapData.regions.find((r) => r.id === displayRegionId)
|
const region = mapData.regions.find((r) => r.id === displayRegionId)
|
||||||
if (!region?.path) return null
|
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 bbox = calculateBoundingBox([region.path])
|
||||||
const padding = Math.max(bbox.width, bbox.height) * 0.1 // 10% padding
|
const viewBox = `${bbox.minX} ${bbox.minY} ${bbox.width} ${bbox.height}`
|
||||||
const viewBox = `${bbox.minX - padding} ${bbox.minY - padding} ${bbox.width + padding * 2} ${bbox.height + padding * 2}`
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: region.path,
|
path: region.path,
|
||||||
|
|
@ -209,6 +237,23 @@ export function GameInfoPanel({
|
||||||
}
|
}
|
||||||
}, [displayRegionId, mapData.regions])
|
}, [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)
|
// Track if animation is in progress (local state based on timestamp)
|
||||||
const [isAnimating, setIsAnimating] = useState(false)
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
|
||||||
|
|
@ -235,6 +280,9 @@ export function GameInfoPanel({
|
||||||
// Ref to measure the takeover container (region name + instructions)
|
// Ref to measure the takeover container (region name + instructions)
|
||||||
const takeoverContainerRef = useRef<HTMLDivElement>(null)
|
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
|
// Calculate the safe scale factor based on viewport size
|
||||||
const [safeScale, setSafeScale] = useState(2.5)
|
const [safeScale, setSafeScale] = useState(2.5)
|
||||||
|
|
||||||
|
|
@ -296,14 +344,69 @@ export function GameInfoPanel({
|
||||||
config: TAKEOVER_ANIMATION_CONFIG,
|
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
|
// 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
|
const showPulseAnimation = isLearningMode && takeoverProgress < 0.5
|
||||||
|
|
||||||
// Sync takeover state to context (so MapRenderer can suppress hot/cold feedback)
|
// Sync takeover state to context (so MapRenderer can suppress hot/cold feedback)
|
||||||
|
// Also consider puzzle piece animation as a takeover state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsInTakeover(isInTakeoverLocal)
|
setIsInTakeover(isInTakeoverLocal || isPuzzlePieceAnimating)
|
||||||
}, [isInTakeoverLocal, setIsInTakeover])
|
}, [isInTakeoverLocal, isPuzzlePieceAnimating, setIsInTakeover])
|
||||||
|
|
||||||
// Reset local UI state when region changes
|
// Reset local UI state when region changes
|
||||||
// Note: nameConfirmationProgress is reset on the server when prompt changes
|
// Note: nameConfirmationProgress is reset on the server when prompt changes
|
||||||
|
|
@ -344,6 +447,37 @@ export function GameInfoPanel({
|
||||||
optimisticLetterCountRef.current = 0
|
optimisticLetterCountRef.current = 0
|
||||||
}, [currentRegionName])
|
}, [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)
|
// Listen for keypresses to confirm letters (only when name confirmation is required)
|
||||||
// Dispatches to shared state so all multiplayer sessions see the same progress
|
// Dispatches to shared state so all multiplayer sessions see the same progress
|
||||||
useEffect(() => {
|
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
|
// so users can type region names like "Côte d'Ivoire" or "São Tomé" with a regular keyboard
|
||||||
const expectedLetter = normalizeToBaseLetter(letterInfo.char)
|
const expectedLetter = normalizeToBaseLetter(letterInfo.char)
|
||||||
|
|
||||||
|
console.log('[LetterConfirm] Keyboard input:', {
|
||||||
|
pressedLetter,
|
||||||
|
letterInfo,
|
||||||
|
expectedLetter,
|
||||||
|
match: pressedLetter === expectedLetter,
|
||||||
|
regionName: currentRegionName,
|
||||||
|
letterIndex: nextLetterIndex,
|
||||||
|
})
|
||||||
|
|
||||||
if (pressedLetter === expectedLetter) {
|
if (pressedLetter === expectedLetter) {
|
||||||
// Optimistically advance count before server responds
|
// Optimistically advance count before server responds
|
||||||
optimisticLetterCountRef.current = nextLetterIndex + 1
|
optimisticLetterCountRef.current = nextLetterIndex + 1
|
||||||
|
|
@ -525,7 +668,7 @@ export function GameInfoPanel({
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* Takeover overlay - contains scrim backdrop, region shape, and takeover text */}
|
{/* 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
|
<div
|
||||||
data-element="takeover-overlay"
|
data-element="takeover-overlay"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -540,10 +683,10 @@ export function GameInfoPanel({
|
||||||
transition: 'opacity 0.3s ease-out',
|
transition: 'opacity 0.3s ease-out',
|
||||||
})}
|
})}
|
||||||
style={{
|
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
|
<div
|
||||||
data-element="takeover-scrim"
|
data-element="takeover-scrim"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -552,36 +695,105 @@ export function GameInfoPanel({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
transition: 'opacity 0.3s ease-out',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)',
|
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)',
|
||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
WebkitBackdropFilter: 'blur(8px)',
|
WebkitBackdropFilter: 'blur(8px)',
|
||||||
|
// Fade out scrim during puzzle piece animation
|
||||||
|
opacity: isPuzzlePieceAnimating ? 0 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Region shape silhouette */}
|
{/* Region shape silhouette - shown during takeover, until animation starts */}
|
||||||
{displayRegionShape && (
|
{/* 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
|
<svg
|
||||||
|
ref={takeoverRegionShapeRef}
|
||||||
data-element="takeover-region-shape"
|
data-element="takeover-region-shape"
|
||||||
viewBox={displayRegionShape.viewBox}
|
viewBox={viewBox}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
className={css({
|
style={{
|
||||||
position: 'absolute',
|
position: 'fixed',
|
||||||
top: 0,
|
left: `${x}px`,
|
||||||
left: 0,
|
top: `${y}px`,
|
||||||
width: '100%',
|
width: `${width}px`,
|
||||||
height: '100%',
|
height: `${height}px`,
|
||||||
})}
|
zIndex: 151,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d={displayRegionShape.path}
|
d={displayRegionShape.path}
|
||||||
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'}
|
fill={isDark ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.35)'}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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 */}
|
{/* Takeover text - CSS centered, only scale is animated */}
|
||||||
|
{/* Hidden during puzzle piece animation */}
|
||||||
|
{!isPuzzlePieceAnimating && (
|
||||||
<animated.div
|
<animated.div
|
||||||
data-element="takeover-content"
|
data-element="takeover-content"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -722,14 +934,16 @@ export function GameInfoPanel({
|
||||||
textAlign: 'center',
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</animated.div>
|
</animated.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* On-screen keyboard for mobile/touch devices - OUTSIDE animated container so it doesn't scale */}
|
{/* On-screen keyboard for mobile/touch devices - OUTSIDE animated container so it doesn't scale */}
|
||||||
{!isGiveUpAnimating &&
|
{!isGiveUpAnimating &&
|
||||||
|
!isPuzzlePieceAnimating &&
|
||||||
requiresNameConfirmation > 0 &&
|
requiresNameConfirmation > 0 &&
|
||||||
!nameConfirmed &&
|
!nameConfirmed &&
|
||||||
currentRegionName && (
|
currentRegionName && (
|
||||||
|
|
@ -775,6 +989,15 @@ export function GameInfoPanel({
|
||||||
const expectedLetter = normalizeToBaseLetter(letterInfo.char)
|
const expectedLetter = normalizeToBaseLetter(letterInfo.char)
|
||||||
const pressedLetter = letter.toLowerCase()
|
const pressedLetter = letter.toLowerCase()
|
||||||
|
|
||||||
|
console.log('[LetterConfirm] Virtual keyboard:', {
|
||||||
|
pressedLetter,
|
||||||
|
letterInfo,
|
||||||
|
expectedLetter,
|
||||||
|
match: pressedLetter === expectedLetter,
|
||||||
|
regionName: currentRegionName,
|
||||||
|
letterIndex: nextLetterIndex,
|
||||||
|
})
|
||||||
|
|
||||||
if (pressedLetter === expectedLetter) {
|
if (pressedLetter === expectedLetter) {
|
||||||
// Optimistically advance count before server responds
|
// Optimistically advance count before server responds
|
||||||
optimisticLetterCountRef.current = nextLetterIndex + 1
|
optimisticLetterCountRef.current = nextLetterIndex + 1
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,8 @@ export function MapRenderer({
|
||||||
celebration,
|
celebration,
|
||||||
setCelebration,
|
setCelebration,
|
||||||
promptStartTime,
|
promptStartTime,
|
||||||
|
puzzlePieceTarget,
|
||||||
|
setPuzzlePieceTarget,
|
||||||
} = useKnowYourWorld()
|
} = useKnowYourWorld()
|
||||||
// Extract force tuning parameters with defaults
|
// Extract force tuning parameters with defaults
|
||||||
const {
|
const {
|
||||||
|
|
@ -1320,31 +1322,103 @@ export function MapRenderer({
|
||||||
// Wrapper function to intercept clicks and trigger celebration for correct regions
|
// Wrapper function to intercept clicks and trigger celebration for correct regions
|
||||||
const handleRegionClickWithCelebration = useCallback(
|
const handleRegionClickWithCelebration = useCallback(
|
||||||
(regionId: string, regionName: string) => {
|
(regionId: string, regionName: string) => {
|
||||||
// If we're already celebrating, ignore clicks
|
// If we're already celebrating or puzzle piece animating, ignore clicks
|
||||||
if (celebration) return
|
if (celebration || puzzlePieceTarget) return
|
||||||
|
|
||||||
// Check if this is the correct region
|
// Check if this is the correct region
|
||||||
if (regionId === currentPrompt) {
|
if (regionId === currentPrompt) {
|
||||||
// Correct! Start celebration
|
// Correct! Calculate celebration type
|
||||||
const metrics = getSearchMetrics(promptStartTime.current)
|
const metrics = getSearchMetrics(promptStartTime.current)
|
||||||
const celebrationType = classifyCelebration(metrics)
|
const celebrationType = classifyCelebration(metrics)
|
||||||
|
|
||||||
// Store pending click for after celebration
|
// Store pending click for after celebration
|
||||||
pendingCelebrationClick.current = { regionId, regionName }
|
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({
|
setCelebration({
|
||||||
regionId,
|
regionId,
|
||||||
regionName,
|
regionName,
|
||||||
type: celebrationType,
|
type: celebrationType,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// Other modes: Start celebration immediately
|
||||||
|
setCelebration({
|
||||||
|
regionId,
|
||||||
|
regionName,
|
||||||
|
type: celebrationType,
|
||||||
|
startTime: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Wrong region - handle immediately
|
// Wrong region - handle immediately
|
||||||
onRegionClick(regionId, regionName)
|
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
|
// Get center of celebrating region for confetti origin
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue