fix(know-your-world): fix celebration timer restart and mobile magnifier dismissal bugs
Bug fixes: 1. Celebration/confetti timers would restart when mouse moved during animation - Root cause: onComplete callback in useEffect dependency array - Fix: Store callback in ref to prevent timer restart on re-render - Fixed in: Confetti.tsx (both components), CelebrationOverlay.tsx 2. Mobile magnifier would dismiss on every other drag release - Root cause: handleMapTouchEnd only checked map dragging, not magnifier dragging - Fix: Also check isMagnifierDragging and isPinching before dismissing - Touch events can escape magnifier bounds and reach map container Also adds debug console logging for touch end handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1e1ce30dbd
commit
055813205a
|
|
@ -11,7 +11,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import type { CelebrationState } from '../Provider'
|
import type { CelebrationState } from '../Provider'
|
||||||
import { ConfettiBurst } from './Confetti'
|
import { ConfettiBurst } from './Confetti'
|
||||||
import { useMusicOptional } from '../music/MusicContext'
|
import { useMusicOptional } from '../music/MusicContext'
|
||||||
|
|
@ -48,6 +48,11 @@ export function CelebrationOverlay({
|
||||||
() => HARD_EARNED_MESSAGES[Math.floor(Math.random() * HARD_EARNED_MESSAGES.length)]
|
() => HARD_EARNED_MESSAGES[Math.floor(Math.random() * HARD_EARNED_MESSAGES.length)]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Store onComplete in a ref so the timer doesn't restart when the callback changes
|
||||||
|
// This fixes a bug where mouse movement during celebration would restart the timer
|
||||||
|
const onCompleteRef = useRef(onComplete)
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
|
||||||
// NOTE: Celebration sound is handled by MusicContext (via the celebration prop)
|
// NOTE: Celebration sound is handled by MusicContext (via the celebration prop)
|
||||||
// We don't play it here to avoid duplicate sounds
|
// We don't play it here to avoid duplicate sounds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -67,11 +72,11 @@ export function CelebrationOverlay({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
onComplete()
|
onCompleteRef.current()
|
||||||
}, 500) // Brief delay for reduced motion
|
}, 500) // Brief delay for reduced motion
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [reducedMotion, onComplete])
|
}, [reducedMotion])
|
||||||
|
|
||||||
// Reduced motion: simple notification only
|
// Reduced motion: simple notification only
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { CelebrationType } from '../Provider'
|
import type { CelebrationType } from '../Provider'
|
||||||
import { CONFETTI_CONFIG, CELEBRATION_TIMING } from '../utils/celebration'
|
import { CELEBRATION_TIMING, CONFETTI_CONFIG } from '../utils/celebration'
|
||||||
|
|
||||||
interface ConfettiProps {
|
interface ConfettiProps {
|
||||||
type: CelebrationType
|
type: CelebrationType
|
||||||
|
|
@ -62,15 +62,19 @@ export function Confetti({ type, origin, onComplete }: ConfettiProps) {
|
||||||
const particles = useMemo(() => generateParticles(type, origin), [type, origin])
|
const particles = useMemo(() => generateParticles(type, origin), [type, origin])
|
||||||
const timing = CELEBRATION_TIMING[type]
|
const timing = CELEBRATION_TIMING[type]
|
||||||
|
|
||||||
|
// Store onComplete in a ref so the timer doesn't restart when the callback changes
|
||||||
|
const onCompleteRef = useRef(onComplete)
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
|
||||||
// Call onComplete when animation finishes
|
// Call onComplete when animation finishes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
onComplete()
|
onCompleteRef.current()
|
||||||
}, timing.confettiDuration)
|
}, timing.confettiDuration)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [timing.confettiDuration, onComplete])
|
}, [timing.confettiDuration])
|
||||||
|
|
||||||
if (isComplete) return null
|
if (isComplete) return null
|
||||||
|
|
||||||
|
|
@ -132,14 +136,19 @@ export function ConfettiBurst({ type, origin, onComplete }: ConfettiProps) {
|
||||||
const particles = useMemo(() => generateParticles(type, origin), [type, origin])
|
const particles = useMemo(() => generateParticles(type, origin), [type, origin])
|
||||||
const timing = CELEBRATION_TIMING[type]
|
const timing = CELEBRATION_TIMING[type]
|
||||||
|
|
||||||
|
// Store onComplete in a ref so the timer doesn't restart when the callback changes
|
||||||
|
// This fixes a bug where mouse movement during celebration would restart the timer
|
||||||
|
const onCompleteRef = useRef(onComplete)
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
onComplete()
|
onCompleteRef.current()
|
||||||
}, timing.confettiDuration)
|
}, timing.confettiDuration)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [timing.confettiDuration, onComplete])
|
}, [timing.confettiDuration])
|
||||||
|
|
||||||
if (isComplete) return null
|
if (isComplete) return null
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useCelebrationAnimation } from '../features/celebration'
|
||||||
import { useCrosshairRotation } from '../features/crosshair'
|
import { useCrosshairRotation } from '../features/crosshair'
|
||||||
import { CustomCursor, HeatCrosshair } from '../features/cursor'
|
import { CustomCursor, HeatCrosshair } from '../features/cursor'
|
||||||
import { AutoZoomDebugOverlay, HotColdDebugPanel, SafeZoneDebugOverlay } from '../features/debug'
|
import { AutoZoomDebugOverlay, HotColdDebugPanel, SafeZoneDebugOverlay } from '../features/debug'
|
||||||
|
import { type MapGameContextValue, MapGameProvider } from '../features/game'
|
||||||
import { useHintAnimation } from '../features/hint'
|
import { useHintAnimation } from '../features/hint'
|
||||||
import {
|
import {
|
||||||
calculatePointerLockMovement,
|
calculatePointerLockMovement,
|
||||||
|
|
@ -17,28 +18,27 @@ import {
|
||||||
useInteractionStateMachine,
|
useInteractionStateMachine,
|
||||||
} from '../features/interaction'
|
} from '../features/interaction'
|
||||||
import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels'
|
import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels'
|
||||||
import { useGiveUpReveal } from '../features/reveal'
|
|
||||||
import { MapGameProvider, type MapGameContextValue } from '../features/game'
|
|
||||||
import {
|
import {
|
||||||
applyPanDelta,
|
applyPanDelta,
|
||||||
calculateTouchMultiplier,
|
calculateTouchMultiplier,
|
||||||
clampToSvgBounds,
|
clampToSvgBounds,
|
||||||
getAdjustedMagnifiedDimensions,
|
getAdjustedMagnifiedDimensions,
|
||||||
getMagnifierDimensions,
|
getMagnifierDimensions,
|
||||||
|
type MagnifierContextValue,
|
||||||
MagnifierCrosshair,
|
MagnifierCrosshair,
|
||||||
MagnifierOverlayWithHandlers,
|
MagnifierOverlayWithHandlers,
|
||||||
MagnifierPixelGrid,
|
MagnifierPixelGrid,
|
||||||
MagnifierProvider,
|
MagnifierProvider,
|
||||||
MagnifierRegions,
|
MagnifierRegions,
|
||||||
parseViewBoxDimensions,
|
parseViewBoxDimensions,
|
||||||
|
type UseMagnifierTouchHandlersOptions,
|
||||||
useMagnifierState,
|
useMagnifierState,
|
||||||
useMagnifierStyle,
|
useMagnifierStyle,
|
||||||
ZoomLinesOverlay,
|
ZoomLinesOverlay,
|
||||||
type MagnifierContextValue,
|
|
||||||
type UseMagnifierTouchHandlersOptions,
|
|
||||||
} from '../features/magnifier'
|
} from '../features/magnifier'
|
||||||
import { NetworkCursors } from '../features/multiplayer'
|
import { NetworkCursors } from '../features/multiplayer'
|
||||||
import { usePrecisionCalculations } from '../features/precision'
|
import { usePrecisionCalculations } from '../features/precision'
|
||||||
|
import { useGiveUpReveal } from '../features/reveal'
|
||||||
import { useGameSettings } from '../features/settings'
|
import { useGameSettings } from '../features/settings'
|
||||||
import {
|
import {
|
||||||
useCanUsePrecisionMode,
|
useCanUsePrecisionMode,
|
||||||
|
|
@ -508,9 +508,7 @@ export function MapRenderer({
|
||||||
// Compute showMagnifier based on device type:
|
// Compute showMagnifier based on device type:
|
||||||
// - Mobile: state machine is authoritative (isMagnifierActive)
|
// - Mobile: state machine is authoritative (isMagnifierActive)
|
||||||
// - Desktop: use magnifierState.isVisible (set by event handlers based on shouldShow logic)
|
// - Desktop: use magnifierState.isVisible (set by event handlers based on shouldShow logic)
|
||||||
const showMagnifier = isTouchDevice
|
const showMagnifier = isTouchDevice ? interaction.isMagnifierActive : magnifierState.isVisible
|
||||||
? interaction.isMagnifierActive
|
|
||||||
: magnifierState.isVisible
|
|
||||||
|
|
||||||
// Ref to magnifier element for tap position calculation
|
// Ref to magnifier element for tap position calculation
|
||||||
const magnifierRef = useRef<HTMLDivElement>(null)
|
const magnifierRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -1025,8 +1023,17 @@ export function MapRenderer({
|
||||||
|
|
||||||
// Request pointer lock on first click
|
// Request pointer lock on first click
|
||||||
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
console.log('[handleContainerClick] Called', {
|
||||||
|
suppressNextClick: suppressNextClickRef.current,
|
||||||
|
pointerLocked,
|
||||||
|
isPointerLockSupported,
|
||||||
|
target: (e.target as Element)?.tagName,
|
||||||
|
currentTarget: (e.currentTarget as Element)?.tagName,
|
||||||
|
})
|
||||||
|
|
||||||
// If we just finished a drag, suppress this click (user was dragging, not clicking)
|
// If we just finished a drag, suppress this click (user was dragging, not clicking)
|
||||||
if (suppressNextClickRef.current) {
|
if (suppressNextClickRef.current) {
|
||||||
|
console.log('[handleContainerClick] Suppressed - drag just finished')
|
||||||
suppressNextClickRef.current = false
|
suppressNextClickRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1035,6 +1042,7 @@ export function MapRenderer({
|
||||||
// This makes the first gameplay click also enable precision mode
|
// This makes the first gameplay click also enable precision mode
|
||||||
// On devices without pointer lock (iPad), skip this and process clicks normally
|
// On devices without pointer lock (iPad), skip this and process clicks normally
|
||||||
if (!pointerLocked && isPointerLockSupported) {
|
if (!pointerLocked && isPointerLockSupported) {
|
||||||
|
console.log('[handleContainerClick] Requesting pointer lock')
|
||||||
requestPointerLock()
|
requestPointerLock()
|
||||||
return // Don't process region click on the first click that requests lock
|
return // Don't process region click on the first click that requests lock
|
||||||
}
|
}
|
||||||
|
|
@ -1259,21 +1267,39 @@ export function MapRenderer({
|
||||||
|
|
||||||
// Handle celebration completion - call the actual click after animation
|
// Handle celebration completion - call the actual click after animation
|
||||||
const handleCelebrationComplete = useCallback(() => {
|
const handleCelebrationComplete = useCallback(() => {
|
||||||
|
console.log('[handleCelebrationComplete] Called', {
|
||||||
|
pending: pendingCelebrationClick.current,
|
||||||
|
})
|
||||||
const pending = pendingCelebrationClick.current
|
const pending = pendingCelebrationClick.current
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
console.log('[handleCelebrationComplete] Clearing celebration and calling onRegionClick')
|
||||||
// Clear celebration state (hook will reset flash progress automatically)
|
// Clear celebration state (hook will reset flash progress automatically)
|
||||||
setCelebration(null)
|
setCelebration(null)
|
||||||
// Then fire the actual click
|
// Then fire the actual click
|
||||||
onRegionClick(pending.regionId, pending.regionName)
|
onRegionClick(pending.regionId, pending.regionName)
|
||||||
pendingCelebrationClick.current = null
|
pendingCelebrationClick.current = null
|
||||||
|
} else {
|
||||||
|
console.log('[handleCelebrationComplete] No pending click - nothing to do')
|
||||||
}
|
}
|
||||||
}, [setCelebration, onRegionClick])
|
}, [setCelebration, onRegionClick])
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
console.log('[handleRegionClickWithCelebration] Called with:', {
|
||||||
|
regionId,
|
||||||
|
regionName,
|
||||||
|
currentPrompt,
|
||||||
|
celebration: celebration
|
||||||
|
? { regionId: celebration.regionId, type: celebration.type }
|
||||||
|
: null,
|
||||||
|
puzzlePieceTarget: puzzlePieceTarget ? { regionId: puzzlePieceTarget.regionId } : null,
|
||||||
|
})
|
||||||
// If we're already celebrating or puzzle piece animating, ignore clicks
|
// If we're already celebrating or puzzle piece animating, ignore clicks
|
||||||
if (celebration || puzzlePieceTarget) return
|
if (celebration || puzzlePieceTarget) {
|
||||||
|
console.log('[handleRegionClickWithCelebration] Blocked - already celebrating or animating')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is the correct region
|
// Check if this is the correct region
|
||||||
if (regionId === currentPrompt) {
|
if (regionId === currentPrompt) {
|
||||||
|
|
@ -2225,19 +2251,36 @@ export function MapRenderer({
|
||||||
}, [onCursorUpdate, gameMode, currentPlayer, localPlayerId, interaction])
|
}, [onCursorUpdate, gameMode, currentPlayer, localPlayerId, interaction])
|
||||||
|
|
||||||
const handleMapTouchEnd = useCallback(() => {
|
const handleMapTouchEnd = useCallback(() => {
|
||||||
const wasDragging = isMobileMapDragging
|
const wasDraggingMap = isMobileMapDragging
|
||||||
|
const wasDraggingMagnifier = interaction.isMagnifierDragging
|
||||||
|
const wasPinching = interaction.isPinching
|
||||||
|
const phaseBefore = interaction.state.mode === 'mobile' ? interaction.state.phase : 'N/A'
|
||||||
|
console.log('[handleMapTouchEnd] Called', {
|
||||||
|
wasDraggingMap,
|
||||||
|
wasDraggingMagnifier,
|
||||||
|
wasPinching,
|
||||||
|
showMagnifier,
|
||||||
|
phaseBefore,
|
||||||
|
hasCursor: !!cursorPositionRef.current,
|
||||||
|
})
|
||||||
mapTouchStartRef.current = null
|
mapTouchStartRef.current = null
|
||||||
|
|
||||||
// Dispatch state machine event for touch end
|
// Dispatch state machine event for touch end
|
||||||
interaction.dispatch({ type: 'TOUCH_END', touchCount: 0 })
|
interaction.dispatch({ type: 'TOUCH_END', touchCount: 0 })
|
||||||
|
|
||||||
if (wasDragging) {
|
// Check if we were interacting with map or magnifier (drag/pinch)
|
||||||
// State machine handles the transition from mapPanning → magnifierActive
|
// If interacting with magnifier, the touch end event shouldn't have come here
|
||||||
// and sets magnifierTriggeredByDrag: true (shows Select button)
|
// (magnifier should capture it) but if it does, we should NOT dismiss the magnifier
|
||||||
// Keep magnifier visible after drag ends - user can tap "Select" button or tap elsewhere to dismiss
|
if (wasDraggingMap || wasDraggingMagnifier || wasPinching) {
|
||||||
// Don't hide magnifier or clear cursor - leave them in place for selection
|
// State machine handles the transition:
|
||||||
|
// - mapPanning → magnifierActive (sets magnifierTriggeredByDrag: true)
|
||||||
|
// - magnifierPanning → magnifierActive
|
||||||
|
// - magnifierPinching → magnifierActive
|
||||||
|
// Keep magnifier visible - user can tap "Select" button or tap elsewhere to dismiss
|
||||||
|
console.log('[handleMapTouchEnd] Was dragging/pinching - keeping magnifier')
|
||||||
} else if (showMagnifier && cursorPositionRef.current) {
|
} else if (showMagnifier && cursorPositionRef.current) {
|
||||||
// User tapped on map (not a drag) while magnifier is visible - dismiss the magnifier
|
// User tapped on map (not a drag) while magnifier is visible - dismiss the magnifier
|
||||||
|
console.log('[handleMapTouchEnd] Dismissing magnifier (tap on map while visible)')
|
||||||
dismissMagnifier()
|
dismissMagnifier()
|
||||||
}
|
}
|
||||||
}, [isMobileMapDragging, showMagnifier, dismissMagnifier, interaction])
|
}, [isMobileMapDragging, showMagnifier, dismissMagnifier, interaction])
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,9 @@
|
||||||
* Provides hint animation and related functionality for the Know Your World game.
|
* Provides hint animation and related functionality for the Know Your World game.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { useHintAnimation, type UseHintAnimationOptions, type UseHintAnimationReturn, type HintActive } from './useHintAnimation'
|
export {
|
||||||
|
useHintAnimation,
|
||||||
|
type UseHintAnimationOptions,
|
||||||
|
type UseHintAnimationReturn,
|
||||||
|
type HintActive,
|
||||||
|
} from './useHintAnimation'
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,14 @@ export function useMagnifierTouchHandlers(
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
const handleMagnifierTouchEnd = useCallback(
|
const handleMagnifierTouchEnd = useCallback(
|
||||||
(e: React.TouchEvent<HTMLDivElement>) => {
|
(e: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
const currentPhase = interaction.state.mode === 'mobile' ? interaction.state.phase : 'N/A'
|
||||||
|
console.log('[handleMagnifierTouchEnd] Called', {
|
||||||
|
currentPhase,
|
||||||
|
isPinchingFromMachine,
|
||||||
|
touchesLength: e.touches.length,
|
||||||
|
changedTouchesLength: e.changedTouches.length,
|
||||||
|
didMove: magnifierDidMoveRef.current,
|
||||||
|
})
|
||||||
// Always stop propagation to prevent map container from receiving touch end
|
// Always stop propagation to prevent map container from receiving touch end
|
||||||
// (which would trigger dismissMagnifier via handleMapTouchEnd)
|
// (which would trigger dismissMagnifier via handleMapTouchEnd)
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -465,6 +473,8 @@ export function useMagnifierTouchHandlers(
|
||||||
type: 'TOUCH_END',
|
type: 'TOUCH_END',
|
||||||
touchCount: e.touches.length, // Number of fingers still touching
|
touchCount: e.touches.length, // Number of fingers still touching
|
||||||
})
|
})
|
||||||
|
console.log('[handleMagnifierTouchEnd] After dispatch, new phase:',
|
||||||
|
interaction.state.mode === 'mobile' ? interaction.state.phase : 'N/A')
|
||||||
|
|
||||||
// State machine is authoritative for dragging state (magnifierPanning phase)
|
// State machine is authoritative for dragging state (magnifierPanning phase)
|
||||||
magnifierTouchStartRef.current = null
|
magnifierTouchStartRef.current = null
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,14 @@ export function useGiveUpReveal(options: UseGiveUpRevealOptions): UseGiveUpRevea
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [giveUpReveal?.timestamp, giveUpAnimation, svgRef, containerRef, fillContainer, navHeightOffset])
|
}, [
|
||||||
|
giveUpReveal?.timestamp,
|
||||||
|
giveUpAnimation,
|
||||||
|
svgRef,
|
||||||
|
containerRef,
|
||||||
|
fillContainer,
|
||||||
|
navHeightOffset,
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
giveUpFlashProgress,
|
giveUpFlashProgress,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue