From e0b762e3ee88771009ede39622a6722b4e366ce2 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 1 Dec 2025 17:36:53 -0600 Subject: [PATCH] feat(know-your-world): add speech announcements and compass-style crosshairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Speech announcements: - Announce region name when takeover shows (Part 1) - Announce "You found {region}" at start of celebration (Part 2) - Add 2-second breather delay between celebrations and next region - Delay starts after BOTH celebration AND speech finish (async tracking) Compass-style crosshairs: - Replace simple crosshairs with compass design on all cursors - Add 12 tick marks around ring (cardinal directions more prominent) - Add red "N" indicator that counter-rotates to always point up - Cardinal ticks are white with dark shadow for contrast - Magnifier compass has precise rotating crosshair lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MapRenderer.tsx | 351 +++++++++++++----- 1 file changed, 254 insertions(+), 97 deletions(-) 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 a9d11002..604b95f7 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 @@ -813,6 +813,7 @@ export function MapRenderer({ // Speech synthesis for reading hints aloud const { + speak, speakWithRegionName, stop: stopSpeaking, isSpeaking, @@ -975,6 +976,123 @@ export function MapRenderer({ hintsLocked, ]) + // Part 1: Announce region name when a new prompt appears (takeover) + // This speaks just the region name when the prompt changes, before hints unlock + // Adds a delay after "You found" to give a breather before the next region + const prevPromptForAnnouncementRef = useRef(null) + const lastFoundAnnouncementTimeRef = useRef(0) + const announcementTimeoutRef = useRef | null>(null) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (announcementTimeoutRef.current) { + clearTimeout(announcementTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + // Clear any pending announcement when prompt changes + if (announcementTimeoutRef.current) { + clearTimeout(announcementTimeoutRef.current) + announcementTimeoutRef.current = null + } + + // Only announce if: + // 1. We have speech support + // 2. We have a new prompt (different from previous) + // 3. We have a region name + if (!isSpeechSupported || !currentPrompt || !currentRegionName) { + prevPromptForAnnouncementRef.current = currentPrompt + return + } + + // Check if this is a new prompt (not just re-render) + if (currentPrompt === prevPromptForAnnouncementRef.current) { + return + } + + prevPromptForAnnouncementRef.current = currentPrompt + + // Calculate delay: give a breather after "You found" announcement + // Wait at least 2 seconds after the last "You found" before announcing next region + const MIN_DELAY_AFTER_FOUND = 2000 + const timeSinceLastFound = Date.now() - lastFoundAnnouncementTimeRef.current + const delay = Math.max(0, MIN_DELAY_AFTER_FOUND - timeSinceLastFound) + + if (delay > 0) { + // Schedule delayed announcement + announcementTimeoutRef.current = setTimeout(() => { + speakWithRegionName(currentRegionName, null, false) + announcementTimeoutRef.current = null + }, delay) + } else { + // No recent "You found", announce immediately + speakWithRegionName(currentRegionName, null, false) + } + }, [currentPrompt, currentRegionName, isSpeechSupported, speakWithRegionName]) + + // Part 2: Announce "You found {region}" at the START of part 2 + // - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in) + // - In other modes: Triggered when celebration is set (immediately after finding) + // Uses just regionId as key to prevent double announcement (puzzle -> celebration transition) + const prevFoundAnnouncementRef = useRef(null) + useEffect(() => { + if (!isSpeechSupported) return + + // Determine what to announce (prioritize puzzlePieceTarget for learning mode) + const regionId = puzzlePieceTarget?.regionId ?? celebration?.regionId + const regionName = puzzlePieceTarget?.regionName ?? celebration?.regionName + + if (regionId && regionName) { + // Use just regionId as key - prevents double announcement when + // puzzlePieceTarget transitions to celebration for the same region + if (regionId !== prevFoundAnnouncementRef.current) { + prevFoundAnnouncementRef.current = regionId + speak(`You found ${regionName}`, false) + } + } else { + // Reset when neither is active + prevFoundAnnouncementRef.current = null + } + }, [puzzlePieceTarget, celebration, isSpeechSupported, speak]) + + // Track when BOTH celebration starts AND "You found" speech finishes + // The breather should only begin after both are complete + const celebrationActiveRef = useRef(false) + const waitingForSpeechToFinishRef = useRef(false) + const prevIsSpeakingRef = useRef(false) + + // Track celebration state + useEffect(() => { + if (celebration) { + celebrationActiveRef.current = true + // If speech is currently happening, wait for it to finish + if (isSpeaking) { + waitingForSpeechToFinishRef.current = true + } else { + // Speech already done (or not speaking), record time now + lastFoundAnnouncementTimeRef.current = Date.now() + } + } else { + celebrationActiveRef.current = false + waitingForSpeechToFinishRef.current = false + } + }, [celebration, isSpeaking]) + + // Track when speech finishes - if we were waiting, record the time + useEffect(() => { + const speechJustFinished = prevIsSpeakingRef.current && !isSpeaking + prevIsSpeakingRef.current = isSpeaking + + if (speechJustFinished && waitingForSpeechToFinishRef.current) { + // Speech just finished and we were waiting for it + lastFoundAnnouncementTimeRef.current = Date.now() + waitingForSpeechToFinishRef.current = false + } + }, [isSpeaking]) + // Hot/cold audio feedback hook // Enabled if: 1) assistance level allows it, 2) user toggle is on // 3) either has fine pointer (desktop) OR magnifier is active (mobile) @@ -4028,7 +4146,7 @@ export function MapRenderer({ transition: 'transform 0.1s ease-out', }} > - {/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */} + {/* Compass-style crosshair with heat effects - ring rotates, N stays fixed */} - {/* Cross lines - top */} - - {/* Cross lines - bottom */} - - {/* Cross lines - left */} - - {/* Cross lines - right */} - + {/* Compass tick marks - 12 ticks around the ring */} + {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((angle) => { + const isCardinal = angle % 90 === 0 + const rad = (angle * Math.PI) / 180 + const innerR = isCardinal ? 9 : 11 + const outerR = 13 + return ( + + ) + })} {/* Center dot */} + {/* Counter-rotating group to keep N fixed pointing up */} + `rotate(${-a}deg)`), + }} + > + {/* North indicator - red triangle pointing up */} + + {/* Cursor region name label - shows what to find under the cursor */} @@ -4187,7 +4295,7 @@ export function MapRenderer({ transform: 'translate(-50%, -50%)', }} > - {/* Enhanced SVG crosshair with heat effects - uses spring-driven rotation */} + {/* Compass-style crosshair with heat effects - ring rotates, N stays fixed */} - {/* Cross lines - top */} - - {/* Cross lines - bottom */} - - {/* Cross lines - left */} - - {/* Cross lines - right */} - + {/* Compass tick marks - 12 ticks around the ring */} + {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((angle) => { + const isCardinal = angle % 90 === 0 + const rad = (angle * Math.PI) / 180 + const innerR = isCardinal ? 10 : 14 + const outerR = 16 + return ( + + ) + })} {/* Center dot */} - + + {/* Counter-rotating group to keep N fixed pointing up */} + `rotate(${-a}deg)`), + }} + > + {/* North indicator - red triangle pointing up */} + + ) @@ -4534,12 +4632,16 @@ export function MapRenderer({ isDark, effectiveHotColdEnabled ) - const crosshairRadius = viewBoxWidth / 60 - const crosshairLineLength = viewBoxWidth / 30 + const crosshairRadius = viewBoxWidth / 100 + const crosshairLineLength = viewBoxWidth / 50 + + const tickInnerR = crosshairRadius * 0.7 + const tickOuterR = crosshairRadius + const northIndicatorSize = crosshairRadius * 0.35 return ( <> - {/* Crosshair with separate translation and rotation */} + {/* Compass-style crosshair with separate translation and rotation */} {/* Outer handles translation (follows cursor) */} {/* Inner animated.g handles rotation via spring-driven animation */} @@ -4560,7 +4662,48 @@ export function MapRenderer({ vectorEffect="non-scaling-stroke" opacity={heatStyle.opacity} /> - {/* Horizontal crosshair line - drawn at origin */} + {/* Compass tick marks - 12 ticks around the ring */} + {[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((angle) => { + const isCardinal = angle % 90 === 0 + const rad = (angle * Math.PI) / 180 + // Cardinals extend much further inward for prominence + const innerR = isCardinal ? tickInnerR * 0.5 : tickInnerR + const outerR = isCardinal ? tickOuterR * 1.15 : tickOuterR + return ( + + {/* Dark shadow for cardinal ticks */} + {isCardinal && ( + + )} + {/* Main tick */} + + + ) + })} + {/* Horizontal crosshair line - precise targeting */} - {/* Vertical crosshair line - drawn at origin */} + {/* Vertical crosshair line - precise targeting */} + {/* Counter-rotating group to keep N fixed pointing up */} + `rotate(${-a}deg)`), + transformOrigin: '0 0', + }} + > + {/* North indicator - red triangle pointing up */} + +