From 055813205a680e4204329861589637ea28160d23 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 4 Dec 2025 13:17:48 -0600 Subject: [PATCH] fix(know-your-world): fix celebration timer restart and mobile magnifier dismissal bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/CelebrationOverlay.tsx | 11 ++- .../know-your-world/components/Confetti.tsx | 21 ++++-- .../components/MapRenderer.tsx | 71 +++++++++++++++---- .../know-your-world/features/hint/index.ts | 7 +- .../magnifier/useMagnifierTouchHandlers.ts | 10 +++ .../features/reveal/useGiveUpReveal.ts | 9 ++- 6 files changed, 104 insertions(+), 25 deletions(-) diff --git a/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx b/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx index 922837af..ca26fa4e 100644 --- a/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/CelebrationOverlay.tsx @@ -11,7 +11,7 @@ 'use client' import { css } from '@styled/css' -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import type { CelebrationState } from '../Provider' import { ConfettiBurst } from './Confetti' import { useMusicOptional } from '../music/MusicContext' @@ -48,6 +48,11 @@ export function CelebrationOverlay({ () => 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) // We don't play it here to avoid duplicate sounds useEffect(() => { @@ -67,11 +72,11 @@ export function CelebrationOverlay({ useEffect(() => { if (reducedMotion) { const timer = setTimeout(() => { - onComplete() + onCompleteRef.current() }, 500) // Brief delay for reduced motion return () => clearTimeout(timer) } - }, [reducedMotion, onComplete]) + }, [reducedMotion]) // Reduced motion: simple notification only if (reducedMotion) { diff --git a/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx b/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx index a2aa5312..36d65157 100644 --- a/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/Confetti.tsx @@ -6,9 +6,9 @@ */ import { css } from '@styled/css' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { CelebrationType } from '../Provider' -import { CONFETTI_CONFIG, CELEBRATION_TIMING } from '../utils/celebration' +import { CELEBRATION_TIMING, CONFETTI_CONFIG } from '../utils/celebration' interface ConfettiProps { type: CelebrationType @@ -62,15 +62,19 @@ export function Confetti({ type, origin, onComplete }: ConfettiProps) { const particles = useMemo(() => generateParticles(type, origin), [type, origin]) 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 useEffect(() => { const timer = setTimeout(() => { setIsComplete(true) - onComplete() + onCompleteRef.current() }, timing.confettiDuration) return () => clearTimeout(timer) - }, [timing.confettiDuration, onComplete]) + }, [timing.confettiDuration]) if (isComplete) return null @@ -132,14 +136,19 @@ export function ConfettiBurst({ type, origin, onComplete }: ConfettiProps) { const particles = useMemo(() => generateParticles(type, origin), [type, origin]) 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(() => { const timer = setTimeout(() => { setIsComplete(true) - onComplete() + onCompleteRef.current() }, timing.confettiDuration) return () => clearTimeout(timer) - }, [timing.confettiDuration, onComplete]) + }, [timing.confettiDuration]) if (isComplete) return null diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index bba497e9..dab4d5df 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -10,6 +10,7 @@ import { useCelebrationAnimation } from '../features/celebration' import { useCrosshairRotation } from '../features/crosshair' import { CustomCursor, HeatCrosshair } from '../features/cursor' import { AutoZoomDebugOverlay, HotColdDebugPanel, SafeZoneDebugOverlay } from '../features/debug' +import { type MapGameContextValue, MapGameProvider } from '../features/game' import { useHintAnimation } from '../features/hint' import { calculatePointerLockMovement, @@ -17,28 +18,27 @@ import { useInteractionStateMachine, } from '../features/interaction' import { getRenderedViewport, LabelLayer, useD3ForceLabels } from '../features/labels' -import { useGiveUpReveal } from '../features/reveal' -import { MapGameProvider, type MapGameContextValue } from '../features/game' import { applyPanDelta, calculateTouchMultiplier, clampToSvgBounds, getAdjustedMagnifiedDimensions, getMagnifierDimensions, + type MagnifierContextValue, MagnifierCrosshair, MagnifierOverlayWithHandlers, MagnifierPixelGrid, MagnifierProvider, MagnifierRegions, parseViewBoxDimensions, + type UseMagnifierTouchHandlersOptions, useMagnifierState, useMagnifierStyle, ZoomLinesOverlay, - type MagnifierContextValue, - type UseMagnifierTouchHandlersOptions, } from '../features/magnifier' import { NetworkCursors } from '../features/multiplayer' import { usePrecisionCalculations } from '../features/precision' +import { useGiveUpReveal } from '../features/reveal' import { useGameSettings } from '../features/settings' import { useCanUsePrecisionMode, @@ -508,9 +508,7 @@ export function MapRenderer({ // Compute showMagnifier based on device type: // - Mobile: state machine is authoritative (isMagnifierActive) // - Desktop: use magnifierState.isVisible (set by event handlers based on shouldShow logic) - const showMagnifier = isTouchDevice - ? interaction.isMagnifierActive - : magnifierState.isVisible + const showMagnifier = isTouchDevice ? interaction.isMagnifierActive : magnifierState.isVisible // Ref to magnifier element for tap position calculation const magnifierRef = useRef(null) @@ -1025,8 +1023,17 @@ export function MapRenderer({ // Request pointer lock on first click const handleContainerClick = (e: React.MouseEvent) => { + 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 (suppressNextClickRef.current) { + console.log('[handleContainerClick] Suppressed - drag just finished') suppressNextClickRef.current = false return } @@ -1035,6 +1042,7 @@ export function MapRenderer({ // This makes the first gameplay click also enable precision mode // On devices without pointer lock (iPad), skip this and process clicks normally if (!pointerLocked && isPointerLockSupported) { + console.log('[handleContainerClick] Requesting pointer lock') requestPointerLock() 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 const handleCelebrationComplete = useCallback(() => { + console.log('[handleCelebrationComplete] Called', { + pending: pendingCelebrationClick.current, + }) const pending = pendingCelebrationClick.current if (pending) { + console.log('[handleCelebrationComplete] Clearing celebration and calling onRegionClick') // Clear celebration state (hook will reset flash progress automatically) setCelebration(null) // Then fire the actual click onRegionClick(pending.regionId, pending.regionName) pendingCelebrationClick.current = null + } else { + console.log('[handleCelebrationComplete] No pending click - nothing to do') } }, [setCelebration, onRegionClick]) // Wrapper function to intercept clicks and trigger celebration for correct regions const handleRegionClickWithCelebration = useCallback( (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 (celebration || puzzlePieceTarget) return + if (celebration || puzzlePieceTarget) { + console.log('[handleRegionClickWithCelebration] Blocked - already celebrating or animating') + return + } // Check if this is the correct region if (regionId === currentPrompt) { @@ -2225,19 +2251,36 @@ export function MapRenderer({ }, [onCursorUpdate, gameMode, currentPlayer, localPlayerId, interaction]) 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 // Dispatch state machine event for touch end interaction.dispatch({ type: 'TOUCH_END', touchCount: 0 }) - if (wasDragging) { - // State machine handles the transition from mapPanning → magnifierActive - // and sets magnifierTriggeredByDrag: true (shows Select button) - // Keep magnifier visible after drag ends - user can tap "Select" button or tap elsewhere to dismiss - // Don't hide magnifier or clear cursor - leave them in place for selection + // Check if we were interacting with map or magnifier (drag/pinch) + // If interacting with magnifier, the touch end event shouldn't have come here + // (magnifier should capture it) but if it does, we should NOT dismiss the magnifier + if (wasDraggingMap || wasDraggingMagnifier || wasPinching) { + // 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) { // 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() } }, [isMobileMapDragging, showMagnifier, dismissMagnifier, interaction]) diff --git a/apps/web/src/arcade-games/know-your-world/features/hint/index.ts b/apps/web/src/arcade-games/know-your-world/features/hint/index.ts index 557c5d20..69ef9945 100644 --- a/apps/web/src/arcade-games/know-your-world/features/hint/index.ts +++ b/apps/web/src/arcade-games/know-your-world/features/hint/index.ts @@ -4,4 +4,9 @@ * 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' diff --git a/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouchHandlers.ts b/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouchHandlers.ts index 68cc560b..9f310937 100644 --- a/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouchHandlers.ts +++ b/apps/web/src/arcade-games/know-your-world/features/magnifier/useMagnifierTouchHandlers.ts @@ -435,6 +435,14 @@ export function useMagnifierTouchHandlers( // ------------------------------------------------------------------------- const handleMagnifierTouchEnd = useCallback( (e: React.TouchEvent) => { + 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 // (which would trigger dismissMagnifier via handleMapTouchEnd) e.stopPropagation() @@ -465,6 +473,8 @@ export function useMagnifierTouchHandlers( type: 'TOUCH_END', 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) magnifierTouchStartRef.current = null diff --git a/apps/web/src/arcade-games/know-your-world/features/reveal/useGiveUpReveal.ts b/apps/web/src/arcade-games/know-your-world/features/reveal/useGiveUpReveal.ts index 5adf241d..97c55d7e 100644 --- a/apps/web/src/arcade-games/know-your-world/features/reveal/useGiveUpReveal.ts +++ b/apps/web/src/arcade-games/know-your-world/features/reveal/useGiveUpReveal.ts @@ -218,7 +218,14 @@ export function useGiveUpReveal(options: UseGiveUpRevealOptions): UseGiveUpRevea clearTimeout(timeoutId) } } - }, [giveUpReveal?.timestamp, giveUpAnimation, svgRef, containerRef, fillContainer, navHeightOffset]) + }, [ + giveUpReveal?.timestamp, + giveUpAnimation, + svgRef, + containerRef, + fillContainer, + navHeightOffset, + ]) return { giveUpFlashProgress,