feat(know-your-world): add learning mode takeover animation and fix give-up sequence
- Add react-spring powered takeover animation in learning mode - Region name scales up and centers on screen - Progress-based transition (letters typed / letters needed) - Blurry backdrop scrim with smooth CSS transitions - Nav bar remains accessible above scrim - Fix give-up sequence timing - Show given-up region name during animation instead of next region - Hide type-to-unlock instruction during give-up - Suppress takeover animation during give-up to avoid conflict - Add remaining count display at top of prompt pane - Add progress gradient background to prompt pane 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7e7a8dc1e8
commit
3fd8472e68
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useSpring, animated, to } from '@react-spring/web'
|
||||||
import { useViewerId } from '@/lib/arcade/game-sdk'
|
import { useViewerId } from '@/lib/arcade/game-sdk'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,6 +26,8 @@ import {
|
||||||
const GIVE_UP_ANIMATION_DURATION = 2000
|
const GIVE_UP_ANIMATION_DURATION = 2000
|
||||||
// Duration for the "attention grab" phase of the name display (ms)
|
// Duration for the "attention grab" phase of the name display (ms)
|
||||||
const NAME_ATTENTION_DURATION = 3000
|
const NAME_ATTENTION_DURATION = 3000
|
||||||
|
// React-spring config for smooth takeover transitions
|
||||||
|
const TAKEOVER_ANIMATION_CONFIG = { tension: 170, friction: 20 }
|
||||||
|
|
||||||
// Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji)
|
// Helper to get hot/cold feedback emoji (matches MapRenderer's getHotColdEmoji)
|
||||||
function getHotColdEmoji(type: FeedbackType | null | undefined): string {
|
function getHotColdEmoji(type: FeedbackType | null | undefined): string {
|
||||||
|
|
@ -75,9 +78,6 @@ export function GameInfoPanel({
|
||||||
progress,
|
progress,
|
||||||
onHintsUnlock,
|
onHintsUnlock,
|
||||||
}: GameInfoPanelProps) {
|
}: GameInfoPanelProps) {
|
||||||
// Get flag emoji for world map countries (not USA states)
|
|
||||||
const flagEmoji =
|
|
||||||
selectedMap === 'world' && currentRegionId ? getCountryFlagEmoji(currentRegionId) : ''
|
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
const { state, lastError, clearError, giveUp, controlsState } = useKnowYourWorld()
|
const { state, lastError, clearError, giveUp, controlsState } = useKnowYourWorld()
|
||||||
|
|
@ -133,6 +133,18 @@ export function GameInfoPanel({
|
||||||
return lastError
|
return lastError
|
||||||
}, [lastError, currentDifficultyLevel])
|
}, [lastError, currentDifficultyLevel])
|
||||||
|
|
||||||
|
// During give-up animation, show the given-up region's name instead of the next region
|
||||||
|
const displayRegionName = isGiveUpAnimating
|
||||||
|
? state.giveUpReveal?.regionName ?? currentRegionName
|
||||||
|
: currentRegionName
|
||||||
|
const displayRegionId = isGiveUpAnimating
|
||||||
|
? state.giveUpReveal?.regionId ?? currentRegionId
|
||||||
|
: currentRegionId
|
||||||
|
|
||||||
|
// Get flag emoji for the displayed region (not necessarily the current prompt)
|
||||||
|
const displayFlagEmoji =
|
||||||
|
selectedMap === 'world' && displayRegionId ? getCountryFlagEmoji(displayRegionId) : ''
|
||||||
|
|
||||||
// Track if animation is in progress (local state based on timestamp)
|
// Track if animation is in progress (local state based on timestamp)
|
||||||
const [isAnimating, setIsAnimating] = useState(false)
|
const [isAnimating, setIsAnimating] = useState(false)
|
||||||
|
|
||||||
|
|
@ -148,8 +160,89 @@ export function GameInfoPanel({
|
||||||
return getAssistanceLevel(state.assistanceLevel)
|
return getAssistanceLevel(state.assistanceLevel)
|
||||||
}, [state.assistanceLevel])
|
}, [state.assistanceLevel])
|
||||||
|
|
||||||
// Check if name confirmation is required
|
// Check if name confirmation is required (learning mode)
|
||||||
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
|
const requiresNameConfirmation = assistanceConfig.nameConfirmationLetters ?? 0
|
||||||
|
const isLearningMode = state.assistanceLevel === 'learning'
|
||||||
|
|
||||||
|
// Ref to measure the takeover container (region name + instructions)
|
||||||
|
const takeoverContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Calculate the safe scale factor and offsets for perfect centering
|
||||||
|
const [safeScale, setSafeScale] = useState(2.5)
|
||||||
|
const [centerXOffset, setCenterXOffset] = useState(0)
|
||||||
|
const [centerYOffset, setCenterYOffset] = useState(0)
|
||||||
|
|
||||||
|
// Measure container and calculate safe scale + centering offsets when region changes
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!currentRegionName || !isLearningMode) return
|
||||||
|
|
||||||
|
// Use requestAnimationFrame to ensure text has rendered
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (takeoverContainerRef.current) {
|
||||||
|
const rect = takeoverContainerRef.current.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Calculate max scale that keeps element within viewport bounds
|
||||||
|
// Leave 40px padding on each side
|
||||||
|
const maxWidthScale = rect.width > 0 ? (window.innerWidth - 80) / rect.width : 2.5
|
||||||
|
const maxHeightScale = rect.height > 0 ? (window.innerHeight - 80) / rect.height : 2.5
|
||||||
|
// Use the smaller of width/height constraints, clamped between 1.5 and 3.5
|
||||||
|
const calculatedScale = Math.min(maxWidthScale, maxHeightScale)
|
||||||
|
setSafeScale(Math.max(1.5, Math.min(3.5, calculatedScale)))
|
||||||
|
|
||||||
|
// Calculate X offset to center horizontally on screen
|
||||||
|
const elementCenterX = rect.left + rect.width / 2
|
||||||
|
const screenCenterX = window.innerWidth / 2
|
||||||
|
setCenterXOffset(screenCenterX - elementCenterX)
|
||||||
|
|
||||||
|
// Calculate Y offset to center vertically on screen
|
||||||
|
const elementCenterY = rect.top + rect.height / 2
|
||||||
|
const screenCenterY = window.innerHeight / 2
|
||||||
|
setCenterYOffset(screenCenterY - elementCenterY)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(rafId)
|
||||||
|
}, [currentRegionName, isLearningMode])
|
||||||
|
|
||||||
|
// Calculate takeover progress based on letters typed (0 = full takeover, 1 = complete)
|
||||||
|
// Suppress takeover during give up animation to avoid visual conflict
|
||||||
|
const takeoverProgress = useMemo(() => {
|
||||||
|
// During give up animation, suppress takeover (progress = 1 means no takeover)
|
||||||
|
if (isGiveUpAnimating) return 1
|
||||||
|
if (!isLearningMode || requiresNameConfirmation === 0) return 1
|
||||||
|
return Math.min(1, confirmedLetterCount / requiresNameConfirmation)
|
||||||
|
}, [isLearningMode, requiresNameConfirmation, confirmedLetterCount, isGiveUpAnimating])
|
||||||
|
|
||||||
|
// Memoize spring target values to avoid recalculating on every render
|
||||||
|
const springTargets = useMemo(
|
||||||
|
() => ({
|
||||||
|
scale: safeScale - (safeScale - 1) * takeoverProgress,
|
||||||
|
x: centerXOffset * (1 - takeoverProgress),
|
||||||
|
y: centerYOffset * (1 - takeoverProgress),
|
||||||
|
}),
|
||||||
|
[safeScale, centerXOffset, centerYOffset, takeoverProgress]
|
||||||
|
)
|
||||||
|
|
||||||
|
// React-spring animation for takeover transition
|
||||||
|
const takeoverSpring = useSpring({
|
||||||
|
...springTargets,
|
||||||
|
config: TAKEOVER_ANIMATION_CONFIG,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Memoize the transform interpolation to avoid recreating on every render
|
||||||
|
const transformStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
transform: to(
|
||||||
|
[takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y],
|
||||||
|
(s, x, y) => `translate(${x}px, ${y}px) scale(${s})`
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
[takeoverSpring.scale, takeoverSpring.x, takeoverSpring.y]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Memoize whether we're in active takeover mode
|
||||||
|
const isInTakeover = isLearningMode && takeoverProgress < 1
|
||||||
|
const showPulseAnimation = isLearningMode && takeoverProgress < 0.5
|
||||||
|
|
||||||
// Reset name confirmation when region changes
|
// Reset name confirmation when region changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -323,9 +416,40 @@ export function GameInfoPanel({
|
||||||
0% { transform: translateX(-50%) translateY(100%); opacity: 0; }
|
0% { transform: translateX(-50%) translateY(100%); opacity: 0; }
|
||||||
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
100% { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
@keyframes takeoverPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.85; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
{/* Takeover backdrop scrim - blurs background but leaves nav accessible */}
|
||||||
|
{/* Always rendered but uses CSS transitions for smooth fade in/out */}
|
||||||
|
<div
|
||||||
|
data-element="takeover-scrim"
|
||||||
|
className={css({
|
||||||
|
position: 'fixed',
|
||||||
|
// Start below the nav bar to keep it accessible
|
||||||
|
top: '60px',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
// Below takeover content (z-index 200) but above map
|
||||||
|
zIndex: 150,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
// Smooth transitions for opacity and blur
|
||||||
|
transition: 'opacity 0.3s ease-out, backdrop-filter 0.3s ease-out',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(255, 255, 255, 0.5)',
|
||||||
|
// Animate between blurred (in takeover) and clear (not in takeover)
|
||||||
|
backdropFilter: isInTakeover ? 'blur(8px)' : 'blur(0px)',
|
||||||
|
WebkitBackdropFilter: isInTakeover ? 'blur(8px)' : 'blur(0px)',
|
||||||
|
opacity: isInTakeover ? 1 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* TOP-CENTER: Prompt Display - positioned below game nav (~150px) */}
|
{/* TOP-CENTER: Prompt Display - positioned below game nav (~150px) */}
|
||||||
|
{/* Background fills left-to-right as progress increases */}
|
||||||
<div
|
<div
|
||||||
data-element="floating-prompt"
|
data-element="floating-prompt"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -333,19 +457,37 @@ export function GameInfoPanel({
|
||||||
top: { base: '130px', sm: '150px' },
|
top: { base: '130px', sm: '150px' },
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
maxWidth: { base: '92vw', sm: '500px', md: '600px' },
|
width: { base: '92vw', sm: '420px', md: '500px', lg: '560px' },
|
||||||
padding: { base: '2', sm: '3' },
|
padding: { base: '2', sm: '3' },
|
||||||
bg: isDark ? 'blue.900/95' : 'blue.50/95',
|
|
||||||
...floatingPanelBase,
|
...floatingPanelBase,
|
||||||
border: { base: '2px solid', sm: '3px solid' },
|
border: { base: '2px solid', sm: '3px solid' },
|
||||||
borderColor: 'blue.500',
|
borderColor: 'blue.500',
|
||||||
rounded: { base: 'xl', sm: '2xl' },
|
rounded: { base: 'xl', sm: '2xl' },
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
// Overflow handled in inline style to avoid css() recalculation
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
animation: 'glowPulse 2s ease-in-out infinite',
|
animation: 'glowPulse 2s ease-in-out infinite',
|
||||||
|
overflow: isInTakeover ? 'visible' : 'hidden',
|
||||||
|
// Raise z-index above scrim (150) during takeover
|
||||||
|
zIndex: isInTakeover ? 200 : undefined,
|
||||||
|
background: isDark
|
||||||
|
? `linear-gradient(to right, rgba(22, 78, 99, 0.3) ${progress}%, rgba(30, 58, 138, 0.25) ${progress}%)`
|
||||||
|
: `linear-gradient(to right, rgba(34, 197, 94, 0.25) ${progress}%, rgba(59, 130, 246, 0.2) ${progress}%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Remaining count - centered at top */}
|
||||||
|
<div
|
||||||
|
data-element="remaining-count"
|
||||||
|
className={css({
|
||||||
|
fontSize: { base: 'xs', sm: 'sm' },
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isDark ? 'cyan.300' : 'cyan.700',
|
||||||
|
marginBottom: '1',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{totalRegions - foundCount} left
|
||||||
|
</div>
|
||||||
{/* Header row with Find label and controls */}
|
{/* Header row with Find label and controls */}
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -433,190 +575,129 @@ export function GameInfoPanel({
|
||||||
|
|
||||||
{/* Guidance dropdown - only show if there are options to configure */}
|
{/* Guidance dropdown - only show if there are options to configure */}
|
||||||
{shouldShowGuidanceDropdown(assistanceConfig) && (
|
{shouldShowGuidanceDropdown(assistanceConfig) && (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
data-action="guidance-dropdown"
|
data-action="guidance-dropdown"
|
||||||
title="Guidance settings"
|
title="Guidance settings"
|
||||||
className={css({
|
className={css({
|
||||||
padding: '1 2',
|
padding: '1 2',
|
||||||
fontSize: 'xs',
|
fontSize: 'xs',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
bg: 'transparent',
|
bg: 'transparent',
|
||||||
color: isDark ? 'blue.400' : 'blue.600',
|
color: isDark ? 'blue.400' : 'blue.600',
|
||||||
rounded: 'md',
|
rounded: 'md',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isDark ? 'blue.700' : 'blue.300',
|
borderColor: isDark ? 'blue.700' : 'blue.300',
|
||||||
fontWeight: 'medium',
|
fontWeight: 'medium',
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '1',
|
gap: '1',
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
borderColor: isDark ? 'blue.500' : 'blue.400',
|
borderColor: isDark ? 'blue.500' : 'blue.400',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
|
||||||
<span>⚙️</span>
|
|
||||||
<svg
|
|
||||||
className={css({ w: '3', h: '3' })}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<span>⚙️</span>
|
||||||
strokeLinecap="round"
|
<svg
|
||||||
strokeLinejoin="round"
|
className={css({ w: '3', h: '3' })}
|
||||||
strokeWidth={2}
|
fill="none"
|
||||||
d="M19 9l-7 7-7-7"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
>
|
||||||
</button>
|
<path
|
||||||
</DropdownMenu.Trigger>
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
data-element="guidance-dropdown-content"
|
data-element="guidance-dropdown-content"
|
||||||
className={css({
|
className={css({
|
||||||
bg: isDark ? 'gray.800' : 'white',
|
bg: isDark ? 'gray.800' : 'white',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||||
rounded: 'lg',
|
rounded: 'lg',
|
||||||
shadow: 'lg',
|
shadow: 'lg',
|
||||||
padding: '2',
|
padding: '2',
|
||||||
minWidth: '160px',
|
minWidth: '160px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
})}
|
})}
|
||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
align="end"
|
align="end"
|
||||||
>
|
>
|
||||||
{/* Auto-Show Hints toggle - only if hints are available */}
|
{/* Auto-Show Hints toggle - only if hints are available */}
|
||||||
{shouldShowAutoHintToggle(assistanceConfig) &&
|
{shouldShowAutoHintToggle(assistanceConfig) &&
|
||||||
(() => {
|
(() => {
|
||||||
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
data-setting="auto-hint"
|
|
||||||
checked={autoHint}
|
|
||||||
disabled={isLocked}
|
|
||||||
onCheckedChange={() => !isLocked && onAutoHintToggle()}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '2',
|
|
||||||
padding: '2',
|
|
||||||
fontSize: 'xs',
|
|
||||||
cursor: isLocked ? 'not-allowed' : 'pointer',
|
|
||||||
rounded: 'md',
|
|
||||||
color: isLocked
|
|
||||||
? isDark
|
|
||||||
? 'gray.500'
|
|
||||||
: 'gray.400'
|
|
||||||
: isDark
|
|
||||||
? 'gray.200'
|
|
||||||
: 'gray.700',
|
|
||||||
outline: 'none',
|
|
||||||
opacity: isLocked ? 0.6 : 1,
|
|
||||||
_hover: isLocked
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.100',
|
|
||||||
},
|
|
||||||
_focus: isLocked
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.100',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isLocked ? (
|
|
||||||
<span>🔒</span>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.ItemIndicator>
|
|
||||||
<span>✓</span>
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
)}
|
|
||||||
<span className={css({ marginLeft: isLocked || autoHint ? '0' : '4' })}>
|
|
||||||
💡 Auto-Show Hints
|
|
||||||
</span>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Auto-speak toggle - only if speech supported AND hints available */}
|
|
||||||
{isSpeechSupported &&
|
|
||||||
shouldShowAutoSpeakToggle(assistanceConfig) &&
|
|
||||||
(() => {
|
|
||||||
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
|
||||||
return (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
data-setting="auto-speak"
|
|
||||||
checked={autoSpeak}
|
|
||||||
disabled={isLocked}
|
|
||||||
onCheckedChange={() => !isLocked && onAutoSpeakToggle()}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '2',
|
|
||||||
padding: '2',
|
|
||||||
fontSize: 'xs',
|
|
||||||
cursor: isLocked ? 'not-allowed' : 'pointer',
|
|
||||||
rounded: 'md',
|
|
||||||
color: isLocked
|
|
||||||
? isDark
|
|
||||||
? 'gray.500'
|
|
||||||
: 'gray.400'
|
|
||||||
: isDark
|
|
||||||
? 'gray.200'
|
|
||||||
: 'gray.700',
|
|
||||||
outline: 'none',
|
|
||||||
opacity: isLocked ? 0.6 : 1,
|
|
||||||
_hover: isLocked
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.100',
|
|
||||||
},
|
|
||||||
_focus: isLocked
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.100',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isLocked ? (
|
|
||||||
<span>🔒</span>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.ItemIndicator>
|
|
||||||
<span>✓</span>
|
|
||||||
</DropdownMenu.ItemIndicator>
|
|
||||||
)}
|
|
||||||
<span className={css({ marginLeft: isLocked || autoSpeak ? '0' : '4' })}>
|
|
||||||
🔈 Auto Speak
|
|
||||||
</span>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Hot/Cold toggle - only if available */}
|
|
||||||
{showHotCold &&
|
|
||||||
(() => {
|
|
||||||
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu.Separator
|
|
||||||
className={css({
|
|
||||||
height: '1px',
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.200',
|
|
||||||
margin: '1 0',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
data-setting="hot-cold"
|
data-setting="auto-hint"
|
||||||
checked={hotColdEnabled}
|
checked={autoHint}
|
||||||
disabled={isLocked}
|
disabled={isLocked}
|
||||||
onCheckedChange={() => !isLocked && onHotColdToggle?.()}
|
onCheckedChange={() => !isLocked && onAutoHintToggle()}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2',
|
||||||
|
padding: '2',
|
||||||
|
fontSize: 'xs',
|
||||||
|
cursor: isLocked ? 'not-allowed' : 'pointer',
|
||||||
|
rounded: 'md',
|
||||||
|
color: isLocked
|
||||||
|
? isDark
|
||||||
|
? 'gray.500'
|
||||||
|
: 'gray.400'
|
||||||
|
: isDark
|
||||||
|
? 'gray.200'
|
||||||
|
: 'gray.700',
|
||||||
|
outline: 'none',
|
||||||
|
opacity: isLocked ? 0.6 : 1,
|
||||||
|
_hover: isLocked
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.100',
|
||||||
|
},
|
||||||
|
_focus: isLocked
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.100',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isLocked ? (
|
||||||
|
<span>🔒</span>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu.ItemIndicator>
|
||||||
|
<span>✓</span>
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
)}
|
||||||
|
<span className={css({ marginLeft: isLocked || autoHint ? '0' : '4' })}>
|
||||||
|
💡 Auto-Show Hints
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Auto-speak toggle - only if speech supported AND hints available */}
|
||||||
|
{isSpeechSupported &&
|
||||||
|
shouldShowAutoSpeakToggle(assistanceConfig) &&
|
||||||
|
(() => {
|
||||||
|
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
||||||
|
return (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
data-setting="auto-speak"
|
||||||
|
checked={autoSpeak}
|
||||||
|
disabled={isLocked}
|
||||||
|
onCheckedChange={() => !isLocked && onAutoSpeakToggle()}
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -654,171 +735,252 @@ export function GameInfoPanel({
|
||||||
</DropdownMenu.ItemIndicator>
|
</DropdownMenu.ItemIndicator>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={css({
|
className={css({ marginLeft: isLocked || autoSpeak ? '0' : '4' })}
|
||||||
marginLeft: isLocked || hotColdEnabled ? '0' : '4',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{hotColdEnabled ? '🔥' : '❄️'} Hot/Cold
|
🔈 Auto Speak
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenu.CheckboxItem>
|
</DropdownMenu.CheckboxItem>
|
||||||
</>
|
)
|
||||||
)
|
})()}
|
||||||
})()}
|
|
||||||
</DropdownMenu.Content>
|
{/* Hot/Cold toggle - only if available */}
|
||||||
</DropdownMenu.Portal>
|
{showHotCold &&
|
||||||
</DropdownMenu.Root>
|
(() => {
|
||||||
|
const isLocked = requiresNameConfirmation > 0 && !nameConfirmed
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Separator
|
||||||
|
className={css({
|
||||||
|
height: '1px',
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.200',
|
||||||
|
margin: '1 0',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
data-setting="hot-cold"
|
||||||
|
checked={hotColdEnabled}
|
||||||
|
disabled={isLocked}
|
||||||
|
onCheckedChange={() => !isLocked && onHotColdToggle?.()}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2',
|
||||||
|
padding: '2',
|
||||||
|
fontSize: 'xs',
|
||||||
|
cursor: isLocked ? 'not-allowed' : 'pointer',
|
||||||
|
rounded: 'md',
|
||||||
|
color: isLocked
|
||||||
|
? isDark
|
||||||
|
? 'gray.500'
|
||||||
|
: 'gray.400'
|
||||||
|
: isDark
|
||||||
|
? 'gray.200'
|
||||||
|
: 'gray.700',
|
||||||
|
outline: 'none',
|
||||||
|
opacity: isLocked ? 0.6 : 1,
|
||||||
|
_hover: isLocked
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.100',
|
||||||
|
},
|
||||||
|
_focus: isLocked
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.100',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isLocked ? (
|
||||||
|
<span>🔒</span>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu.ItemIndicator>
|
||||||
|
<span>✓</span>
|
||||||
|
</DropdownMenu.ItemIndicator>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
marginLeft: isLocked || hotColdEnabled ? '0' : '4',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{hotColdEnabled ? '🔥' : '❄️'} Hot/Cold
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{/* Animated container for takeover - includes region name and instructions */}
|
||||||
|
<animated.div
|
||||||
|
ref={takeoverContainerRef}
|
||||||
key={currentRegionId || 'empty'} // Re-trigger animation on change
|
key={currentRegionId || 'empty'} // Re-trigger animation on change
|
||||||
data-element="region-name-display"
|
data-element="takeover-container"
|
||||||
className={css({
|
className={css({
|
||||||
fontSize: isAttentionPhase
|
|
||||||
? { base: 'xl', sm: '2xl', md: '3xl' }
|
|
||||||
: { base: 'lg', sm: 'xl', md: '2xl' },
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: isDark ? 'white' : 'blue.900',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
position: 'relative',
|
||||||
gap: { base: '1', sm: '2' },
|
transformOrigin: 'center center',
|
||||||
transition: 'font-size 0.5s ease-out',
|
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
animation: `attentionGrab ${NAME_ATTENTION_DURATION}ms ease-out`,
|
// Dynamic z-index moved to inline style to avoid css() recalculation
|
||||||
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none',
|
zIndex: isInTakeover ? 200 : 'auto',
|
||||||
|
// React-spring animated transform
|
||||||
|
...transformStyle,
|
||||||
|
// Pulse animation during takeover (fades after halfway)
|
||||||
|
animation: showPulseAnimation ? 'takeoverPulse 0.8s ease-in-out infinite' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flagEmoji && (
|
{/* Region name display */}
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
fontSize: isAttentionPhase
|
|
||||||
? { base: 'xl', sm: '2xl', md: '3xl' }
|
|
||||||
: { base: 'lg', sm: 'xl', md: '2xl' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{flagEmoji}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Inline letter highlighting for name confirmation */}
|
|
||||||
<span>
|
|
||||||
{currentRegionName
|
|
||||||
? currentRegionName.split('').map((char, index) => {
|
|
||||||
// Determine if this letter needs confirmation styling
|
|
||||||
const needsConfirmation =
|
|
||||||
requiresNameConfirmation > 0 &&
|
|
||||||
!nameConfirmed &&
|
|
||||||
index < requiresNameConfirmation
|
|
||||||
const isConfirmed = index < confirmedLetterCount
|
|
||||||
const isNextToConfirm = index === confirmedLetterCount && needsConfirmation
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={css({
|
|
||||||
transition: 'all 0.15s ease-out',
|
|
||||||
})}
|
|
||||||
style={{
|
|
||||||
// Dim unconfirmed letters that need confirmation
|
|
||||||
opacity: needsConfirmation && !isConfirmed ? 0.4 : 1,
|
|
||||||
// Highlight the next letter to type
|
|
||||||
textDecoration: isNextToConfirm ? 'underline' : 'none',
|
|
||||||
textDecorationColor: isNextToConfirm
|
|
||||||
? isDark
|
|
||||||
? '#60a5fa'
|
|
||||||
: '#3b82f6'
|
|
||||||
: undefined,
|
|
||||||
textUnderlineOffset: isNextToConfirm ? '4px' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
: '...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type-to-unlock instruction OR inline hint */}
|
|
||||||
{requiresNameConfirmation > 0 && !nameConfirmed ? (
|
|
||||||
<div
|
<div
|
||||||
data-element="type-to-unlock"
|
data-element="region-name-display"
|
||||||
className={css({
|
className={css({
|
||||||
marginTop: '2',
|
fontSize: { base: 'lg', sm: 'xl', md: '2xl' },
|
||||||
fontSize: { base: 'sm', sm: 'md' },
|
fontWeight: 'bold',
|
||||||
color: isDark ? 'amber.300' : 'amber.700',
|
color: isDark ? 'white' : 'blue.900',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '1.5',
|
gap: { base: '1', sm: '2' },
|
||||||
})}
|
})}
|
||||||
|
style={{
|
||||||
|
textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>👆</span>
|
{displayFlagEmoji && (
|
||||||
<span>Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''}</span>
|
<span
|
||||||
|
className={css({
|
||||||
|
fontSize: { base: 'lg', sm: 'xl', md: '2xl' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayFlagEmoji}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Inline letter highlighting for name confirmation */}
|
||||||
|
<span>
|
||||||
|
{displayRegionName
|
||||||
|
? displayRegionName.split('').map((char, index) => {
|
||||||
|
// Determine if this letter needs confirmation styling
|
||||||
|
// Disable during give-up animation (showing the given-up region)
|
||||||
|
const needsConfirmation =
|
||||||
|
!isGiveUpAnimating &&
|
||||||
|
requiresNameConfirmation > 0 &&
|
||||||
|
!nameConfirmed &&
|
||||||
|
index < requiresNameConfirmation
|
||||||
|
const isConfirmed = index < confirmedLetterCount
|
||||||
|
const isNextToConfirm = index === confirmedLetterCount && needsConfirmation
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={css({
|
||||||
|
transition: 'all 0.15s ease-out',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
// Dim unconfirmed letters that need confirmation
|
||||||
|
opacity: needsConfirmation && !isConfirmed ? 0.4 : 1,
|
||||||
|
// Highlight the next letter to type
|
||||||
|
textDecoration: isNextToConfirm ? 'underline' : 'none',
|
||||||
|
textDecorationColor: isNextToConfirm
|
||||||
|
? isDark
|
||||||
|
? '#60a5fa'
|
||||||
|
: '#3b82f6'
|
||||||
|
: undefined,
|
||||||
|
textUnderlineOffset: isNextToConfirm ? '4px' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: '...'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
currentHint && (
|
{/* Type-to-unlock instruction - included in takeover container */}
|
||||||
|
{/* Hide during give-up animation since we're showing the given-up region */}
|
||||||
|
{!isGiveUpAnimating && requiresNameConfirmation > 0 && !nameConfirmed && (
|
||||||
<div
|
<div
|
||||||
data-element="inline-hint"
|
data-element="type-to-unlock"
|
||||||
className={css({
|
className={css({
|
||||||
marginTop: '2',
|
marginTop: '2',
|
||||||
fontSize: 'md',
|
fontSize: { base: 'sm', sm: 'md' },
|
||||||
color: isDark ? 'blue.300' : 'blue.700',
|
color: isDark ? 'amber.300' : 'amber.700',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '1.5',
|
gap: '1.5',
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
maxWidth: '100%',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Speaker button - subtle styling */}
|
<span>⌨️</span>
|
||||||
{isSpeechSupported ? (
|
<span>Type the underlined letter{requiresNameConfirmation > 1 ? 's' : ''}</span>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation()
|
</animated.div>
|
||||||
if (isSpeaking) {
|
|
||||||
onStopSpeaking()
|
{/* Inline hint - shown after name is confirmed (or always in non-learning modes) */}
|
||||||
} else {
|
{currentHint && (requiresNameConfirmation === 0 || nameConfirmed) && (
|
||||||
onSpeak()
|
<div
|
||||||
}
|
data-element="inline-hint"
|
||||||
}}
|
className={css({
|
||||||
data-action="speak-hint"
|
marginTop: '2',
|
||||||
title={isSpeaking ? 'Stop speaking' : 'Read hint aloud'}
|
fontSize: 'md',
|
||||||
className={css({
|
color: isDark ? 'blue.300' : 'blue.700',
|
||||||
background: 'transparent',
|
display: 'flex',
|
||||||
border: 'none',
|
alignItems: 'center',
|
||||||
padding: '0',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
gap: '1.5',
|
||||||
fontSize: 'inherit',
|
whiteSpace: 'nowrap',
|
||||||
lineHeight: 'inherit',
|
overflow: 'hidden',
|
||||||
color: 'inherit',
|
maxWidth: '100%',
|
||||||
opacity: isSpeaking ? 1 : 0.7,
|
})}
|
||||||
transition: 'opacity 0.2s',
|
>
|
||||||
_hover: { opacity: 1 },
|
{/* Speaker button - subtle styling */}
|
||||||
flexShrink: 0,
|
{isSpeechSupported ? (
|
||||||
})}
|
<button
|
||||||
>
|
onClick={(e) => {
|
||||||
{isSpeaking ? '⏹️' : '🔈'}
|
e.stopPropagation()
|
||||||
</button>
|
if (isSpeaking) {
|
||||||
) : (
|
onStopSpeaking()
|
||||||
<span className={css({ opacity: 0.7 })}>💡</span>
|
} else {
|
||||||
)}
|
onSpeak()
|
||||||
<span
|
}
|
||||||
|
}}
|
||||||
|
data-action="speak-hint"
|
||||||
|
title={isSpeaking ? 'Stop speaking' : 'Read hint aloud'}
|
||||||
className={css({
|
className={css({
|
||||||
overflow: 'hidden',
|
background: 'transparent',
|
||||||
textOverflow: 'ellipsis',
|
border: 'none',
|
||||||
|
padding: '0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
opacity: isSpeaking ? 1 : 0.7,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
_hover: { opacity: 1 },
|
||||||
|
flexShrink: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{currentHint}
|
{isSpeaking ? '⏹️' : '🔈'}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
)
|
<span className={css({ opacity: 0.7 })}>💡</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{currentHint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voting status for cooperative mode */}
|
{/* Voting status for cooperative mode */}
|
||||||
|
|
@ -843,63 +1005,6 @@ export function GameInfoPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TOP-LEFT: Progress Indicator - positioned below game nav */}
|
|
||||||
<div
|
|
||||||
data-element="floating-progress"
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: { base: '130px', sm: '150px' },
|
|
||||||
left: { base: '2', sm: '4' },
|
|
||||||
padding: { base: '1.5 2', sm: '2 3' },
|
|
||||||
bg: isDark ? 'gray.800/90' : 'white/90',
|
|
||||||
...floatingPanelBase,
|
|
||||||
rounded: { base: 'lg', sm: 'xl' },
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: '2xs',
|
|
||||||
color: isDark ? 'gray.400' : 'gray.600',
|
|
||||||
fontWeight: 'semibold',
|
|
||||||
display: { base: 'none', sm: 'block' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Progress
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: { base: 'md', sm: 'xl' },
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: isDark ? 'green.400' : 'green.600',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{foundCount}/{totalRegions}
|
|
||||||
</div>
|
|
||||||
{/* Mini progress bar */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '60px',
|
|
||||||
height: '4px',
|
|
||||||
bg: isDark ? 'gray.700' : 'gray.200',
|
|
||||||
rounded: 'full',
|
|
||||||
overflow: 'hidden',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
height: '100%',
|
|
||||||
bg: 'green.500',
|
|
||||||
transition: 'width 0.5s ease',
|
|
||||||
})}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BOTTOM-CENTER: Error Banner (toast-style) */}
|
{/* BOTTOM-CENTER: Error Banner (toast-style) */}
|
||||||
{lastError && (
|
{lastError && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue