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:
Thomas Hallock 2025-12-04 13:17:48 -06:00
parent 1e1ce30dbd
commit 055813205a
6 changed files with 104 additions and 25 deletions

View File

@ -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) {

View File

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

View File

@ -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])

View File

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

View File

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

View File

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