feat(know-your-world): add speech announcements and compass-style crosshairs
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 <noreply@anthropic.com>
This commit is contained in:
parent
0584863bdd
commit
e0b762e3ee
|
|
@ -813,6 +813,7 @@ export function MapRenderer({
|
||||||
|
|
||||||
// Speech synthesis for reading hints aloud
|
// Speech synthesis for reading hints aloud
|
||||||
const {
|
const {
|
||||||
|
speak,
|
||||||
speakWithRegionName,
|
speakWithRegionName,
|
||||||
stop: stopSpeaking,
|
stop: stopSpeaking,
|
||||||
isSpeaking,
|
isSpeaking,
|
||||||
|
|
@ -975,6 +976,123 @@ export function MapRenderer({
|
||||||
hintsLocked,
|
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<string | null>(null)
|
||||||
|
const lastFoundAnnouncementTimeRef = useRef<number>(0)
|
||||||
|
const announcementTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string | null>(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
|
// Hot/cold audio feedback hook
|
||||||
// Enabled if: 1) assistance level allows it, 2) user toggle is on
|
// Enabled if: 1) assistance level allows it, 2) user toggle is on
|
||||||
// 3) either has fine pointer (desktop) OR magnifier is active (mobile)
|
// 3) either has fine pointer (desktop) OR magnifier is active (mobile)
|
||||||
|
|
@ -4028,7 +4146,7 @@ export function MapRenderer({
|
||||||
transition: 'transform 0.1s ease-out',
|
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 */}
|
||||||
<animated.svg
|
<animated.svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
|
|
@ -4048,58 +4166,48 @@ export function MapRenderer({
|
||||||
strokeWidth={crosshairHeatStyle.strokeWidth}
|
strokeWidth={crosshairHeatStyle.strokeWidth}
|
||||||
opacity={crosshairHeatStyle.opacity}
|
opacity={crosshairHeatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
{/* Cross lines - top */}
|
{/* 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 (
|
||||||
<line
|
<line
|
||||||
x1="16"
|
key={angle}
|
||||||
y1="2"
|
x1={16 + innerR * Math.sin(rad)}
|
||||||
x2="16"
|
y1={16 - innerR * Math.cos(rad)}
|
||||||
y2="10"
|
x2={16 + outerR * Math.sin(rad)}
|
||||||
stroke={crosshairHeatStyle.color}
|
y2={16 - outerR * Math.cos(rad)}
|
||||||
strokeWidth={crosshairHeatStyle.strokeWidth}
|
stroke={isCardinal ? 'white' : crosshairHeatStyle.color}
|
||||||
strokeLinecap="round"
|
strokeWidth={isCardinal ? 2 : 1}
|
||||||
opacity={crosshairHeatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - bottom */}
|
|
||||||
<line
|
|
||||||
x1="16"
|
|
||||||
y1="22"
|
|
||||||
x2="16"
|
|
||||||
y2="30"
|
|
||||||
stroke={crosshairHeatStyle.color}
|
|
||||||
strokeWidth={crosshairHeatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity={crosshairHeatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - left */}
|
|
||||||
<line
|
|
||||||
x1="2"
|
|
||||||
y1="16"
|
|
||||||
x2="10"
|
|
||||||
y2="16"
|
|
||||||
stroke={crosshairHeatStyle.color}
|
|
||||||
strokeWidth={crosshairHeatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity={crosshairHeatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - right */}
|
|
||||||
<line
|
|
||||||
x1="22"
|
|
||||||
y1="16"
|
|
||||||
x2="30"
|
|
||||||
y2="16"
|
|
||||||
stroke={crosshairHeatStyle.color}
|
|
||||||
strokeWidth={crosshairHeatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
opacity={crosshairHeatStyle.opacity}
|
opacity={crosshairHeatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{/* Center dot */}
|
{/* Center dot */}
|
||||||
<circle
|
<circle
|
||||||
cx="16"
|
cx="16"
|
||||||
cy="16"
|
cy="16"
|
||||||
r="2"
|
r="1.5"
|
||||||
fill={crosshairHeatStyle.color}
|
fill={crosshairHeatStyle.color}
|
||||||
opacity={crosshairHeatStyle.opacity}
|
opacity={crosshairHeatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
{/* Counter-rotating group to keep N fixed pointing up */}
|
||||||
|
<animated.g
|
||||||
|
style={{
|
||||||
|
transformOrigin: '16px 16px',
|
||||||
|
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* North indicator - red triangle pointing up */}
|
||||||
|
<polygon
|
||||||
|
points="16,1 14,5 18,5"
|
||||||
|
fill="#ef4444"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</animated.g>
|
||||||
</animated.svg>
|
</animated.svg>
|
||||||
</div>
|
</div>
|
||||||
{/* Cursor region name label - shows what to find under the cursor */}
|
{/* Cursor region name label - shows what to find under the cursor */}
|
||||||
|
|
@ -4187,7 +4295,7 @@ export function MapRenderer({
|
||||||
transform: 'translate(-50%, -50%)',
|
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 */}
|
||||||
<animated.svg
|
<animated.svg
|
||||||
width="40"
|
width="40"
|
||||||
height="40"
|
height="40"
|
||||||
|
|
@ -4207,52 +4315,42 @@ export function MapRenderer({
|
||||||
strokeWidth={heatStyle.strokeWidth}
|
strokeWidth={heatStyle.strokeWidth}
|
||||||
opacity={heatStyle.opacity}
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
{/* Cross lines - top */}
|
{/* 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 (
|
||||||
<line
|
<line
|
||||||
x1="20"
|
key={angle}
|
||||||
y1="3"
|
x1={20 + innerR * Math.sin(rad)}
|
||||||
x2="20"
|
y1={20 - innerR * Math.cos(rad)}
|
||||||
y2="12"
|
x2={20 + outerR * Math.sin(rad)}
|
||||||
stroke={heatStyle.color}
|
y2={20 - outerR * Math.cos(rad)}
|
||||||
strokeWidth={heatStyle.strokeWidth}
|
stroke={isCardinal ? 'white' : heatStyle.color}
|
||||||
strokeLinecap="round"
|
strokeWidth={isCardinal ? 2.5 : 1}
|
||||||
opacity={heatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - bottom */}
|
|
||||||
<line
|
|
||||||
x1="20"
|
|
||||||
y1="28"
|
|
||||||
x2="20"
|
|
||||||
y2="37"
|
|
||||||
stroke={heatStyle.color}
|
|
||||||
strokeWidth={heatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity={heatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - left */}
|
|
||||||
<line
|
|
||||||
x1="3"
|
|
||||||
y1="20"
|
|
||||||
x2="12"
|
|
||||||
y2="20"
|
|
||||||
stroke={heatStyle.color}
|
|
||||||
strokeWidth={heatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
opacity={heatStyle.opacity}
|
|
||||||
/>
|
|
||||||
{/* Cross lines - right */}
|
|
||||||
<line
|
|
||||||
x1="28"
|
|
||||||
y1="20"
|
|
||||||
x2="37"
|
|
||||||
y2="20"
|
|
||||||
stroke={heatStyle.color}
|
|
||||||
strokeWidth={heatStyle.strokeWidth}
|
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
opacity={heatStyle.opacity}
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{/* Center dot */}
|
{/* Center dot */}
|
||||||
<circle cx="20" cy="20" r="2" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
<circle cx="20" cy="20" r="1.5" fill={heatStyle.color} opacity={heatStyle.opacity} />
|
||||||
|
{/* Counter-rotating group to keep N fixed pointing up */}
|
||||||
|
<animated.g
|
||||||
|
style={{
|
||||||
|
transformOrigin: '20px 20px',
|
||||||
|
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* North indicator - red triangle pointing up */}
|
||||||
|
<polygon
|
||||||
|
points="20,2 17.5,7 22.5,7"
|
||||||
|
fill="#ef4444"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</animated.g>
|
||||||
</animated.svg>
|
</animated.svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -4534,12 +4632,16 @@ export function MapRenderer({
|
||||||
isDark,
|
isDark,
|
||||||
effectiveHotColdEnabled
|
effectiveHotColdEnabled
|
||||||
)
|
)
|
||||||
const crosshairRadius = viewBoxWidth / 60
|
const crosshairRadius = viewBoxWidth / 100
|
||||||
const crosshairLineLength = viewBoxWidth / 30
|
const crosshairLineLength = viewBoxWidth / 50
|
||||||
|
|
||||||
|
const tickInnerR = crosshairRadius * 0.7
|
||||||
|
const tickOuterR = crosshairRadius
|
||||||
|
const northIndicatorSize = crosshairRadius * 0.35
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Crosshair with separate translation and rotation */}
|
{/* Compass-style crosshair with separate translation and rotation */}
|
||||||
{/* Outer <g> handles translation (follows cursor) */}
|
{/* Outer <g> handles translation (follows cursor) */}
|
||||||
{/* Inner animated.g handles rotation via spring-driven animation */}
|
{/* Inner animated.g handles rotation via spring-driven animation */}
|
||||||
<g transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
|
<g transform={`translate(${cursorSvgX}, ${cursorSvgY})`}>
|
||||||
|
|
@ -4560,7 +4662,48 @@ export function MapRenderer({
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
opacity={heatStyle.opacity}
|
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 (
|
||||||
|
<g key={angle}>
|
||||||
|
{/* Dark shadow for cardinal ticks */}
|
||||||
|
{isCardinal && (
|
||||||
|
<line
|
||||||
|
x1={innerR * Math.sin(rad)}
|
||||||
|
y1={-innerR * Math.cos(rad)}
|
||||||
|
x2={outerR * Math.sin(rad)}
|
||||||
|
y2={-outerR * Math.cos(rad)}
|
||||||
|
stroke="rgba(0,0,0,0.6)"
|
||||||
|
strokeWidth={(viewBoxWidth / 800) * 4}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Main tick */}
|
||||||
|
<line
|
||||||
|
x1={innerR * Math.sin(rad)}
|
||||||
|
y1={-innerR * Math.cos(rad)}
|
||||||
|
x2={outerR * Math.sin(rad)}
|
||||||
|
y2={-outerR * Math.cos(rad)}
|
||||||
|
stroke={isCardinal ? 'white' : heatStyle.color}
|
||||||
|
strokeWidth={
|
||||||
|
isCardinal
|
||||||
|
? (viewBoxWidth / 800) * 2.5
|
||||||
|
: (viewBoxWidth / 1000) * (heatStyle.strokeWidth / 2)
|
||||||
|
}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={heatStyle.opacity}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Horizontal crosshair line - precise targeting */}
|
||||||
<line
|
<line
|
||||||
x1={-crosshairLineLength}
|
x1={-crosshairLineLength}
|
||||||
y1={0}
|
y1={0}
|
||||||
|
|
@ -4571,7 +4714,7 @@ export function MapRenderer({
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
opacity={heatStyle.opacity}
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
{/* Vertical crosshair line - drawn at origin */}
|
{/* Vertical crosshair line - precise targeting */}
|
||||||
<line
|
<line
|
||||||
x1={0}
|
x1={0}
|
||||||
y1={-crosshairLineLength}
|
y1={-crosshairLineLength}
|
||||||
|
|
@ -4582,6 +4725,20 @@ export function MapRenderer({
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
opacity={heatStyle.opacity}
|
opacity={heatStyle.opacity}
|
||||||
/>
|
/>
|
||||||
|
{/* Counter-rotating group to keep N fixed pointing up */}
|
||||||
|
<animated.g
|
||||||
|
style={{
|
||||||
|
transform: rotationAngle.to((a) => `rotate(${-a}deg)`),
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* North indicator - red triangle pointing up */}
|
||||||
|
<polygon
|
||||||
|
points={`0,${-crosshairRadius - northIndicatorSize * 0.5} ${-northIndicatorSize * 0.5},${-crosshairRadius + northIndicatorSize * 0.5} ${northIndicatorSize * 0.5},${-crosshairRadius + northIndicatorSize * 0.5}`}
|
||||||
|
fill="#ef4444"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</animated.g>
|
||||||
</animated.g>
|
</animated.g>
|
||||||
</g>
|
</g>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue