From fa1514d351d90010a91e73f5c18fb23115154162 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Sun, 30 Nov 2025 17:49:25 -0600 Subject: [PATCH] feat(know-your-world): improve magnifier UX and hide abacus on games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Magnifier improvements: - Add auto-zoom when dragging on magnifier (mobile) - Add desktop click-and-drag to show magnifier (like shift key) - Add fullscreen expand button (mobile only, top-right corner) - Add Select button inside magnifier (mobile only, bottom-right) - Add Full Map button to exit fullscreen (mobile only, bottom-left) - Select button disabled when crosshair is over ocean or found region - All magnifier buttons only appear on touch devices - Click-drag magnifier works in pointer lock mode Abacus visibility: - Hide floating abacus on all /arcade/* routes by default - Games can opt-in via setShowInGame(true) context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/GameInfoPanel.tsx | 58 ++- .../components/MapRenderer.tsx | 453 ++++++++++++++---- .../components/PlayingPhase.tsx | 24 +- .../music/MusicControlModal.tsx | 448 +++++++++++++++++ .../music/MusicControlPanel.tsx | 19 +- .../know-your-world/music/index.ts | 1 + apps/web/src/components/MyAbacus.tsx | 11 +- apps/web/src/contexts/MyAbacusContext.tsx | 8 +- 8 files changed, 896 insertions(+), 126 deletions(-) create mode 100644 apps/web/src/arcade-games/know-your-world/music/MusicControlModal.tsx diff --git a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx index 6b13d277..ec8ffebe 100644 --- a/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/GameInfoPanel.tsx @@ -5,7 +5,6 @@ import { css } from '@styled/css' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useSpring, animated } from '@react-spring/web' import { useViewerId } from '@/lib/arcade/game-sdk' -import { useMyAbacus } from '@/contexts/MyAbacusContext' import { useTheme } from '@/contexts/ThemeContext' import { calculateBoundingBox, @@ -25,6 +24,7 @@ import { shouldShowAutoSpeakToggle, } from '../utils/guidanceVisibility' import { SimpleLetterKeyboard, useIsTouchDevice } from './SimpleLetterKeyboard' +import { MusicControlModal, useMusic } from '../music' // Animation duration in ms - must match MapRenderer const GIVE_UP_ANIMATION_DURATION = 2000 @@ -115,8 +115,9 @@ export function GameInfoPanel({ // Touch device detection for virtual keyboard const isTouchDevice = useIsTouchDevice() - // Get MyAbacus context to hide it when virtual keyboard is shown - const { setIsHidden: setAbacusHidden } = useMyAbacus() + // Music context and modal state + const music = useMusic() + const [isMusicModalOpen, setIsMusicModalOpen] = useState(false) // Get current difficulty level config const currentDifficultyLevel = useMemo(() => { @@ -269,21 +270,6 @@ export function GameInfoPanel({ setIsInTakeover(isInTakeoverLocal) }, [isInTakeoverLocal, setIsInTakeover]) - // Hide the MyAbacus when virtual keyboard is shown (touch devices only) - // This prevents the floating abacus button from overlapping with the keyboard - const shouldShowVirtualKeyboard = - isTouchDevice && - !isGiveUpAnimating && - requiresNameConfirmation > 0 && - !nameConfirmed && - !!currentRegionName - - useEffect(() => { - setAbacusHidden(shouldShowVirtualKeyboard) - // Cleanup: ensure we unhide when component unmounts - return () => setAbacusHidden(false) - }, [shouldShowVirtualKeyboard, setAbacusHidden]) - // Reset local UI state when region changes // Note: nameConfirmationProgress is reset on the server when prompt changes useEffect(() => { @@ -1069,6 +1055,39 @@ export function GameInfoPanel({ ) })()} + + {/* Music settings - always available */} + + setIsMusicModalOpen(true)} + className={css({ + display: 'flex', + alignItems: 'center', + gap: '2', + padding: '2', + fontSize: 'xs', + cursor: 'pointer', + rounded: 'md', + color: isDark ? 'gray.200' : 'gray.700', + outline: 'none', + _hover: { + bg: isDark ? 'gray.700' : 'gray.100', + }, + _focus: { + bg: isDark ? 'gray.700' : 'gray.100', + }, + })} + > + {music.isPlaying ? 'đŸŽĩ' : '🔇'} + Music Settings + @@ -1317,6 +1336,9 @@ export function GameInfoPanel({ )} + + {/* Music Control Modal */} + ) } 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 9f0ad028..9e3cdeb0 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 @@ -506,6 +506,15 @@ export function MapRenderer({ const [smallestRegionSize, setSmallestRegionSize] = useState(Infinity) const [shiftPressed, setShiftPressed] = useState(false) + // Desktop click-and-drag magnifier state + const [isDesktopMapDragging, setIsDesktopMapDragging] = useState(false) + const desktopDragStartRef = useRef<{ x: number; y: number } | null>(null) + const desktopDragDidMoveRef = useRef(false) + // Track last drag position - magnifier stays visible until cursor moves threshold away + const lastDragPositionRef = useRef<{ x: number; y: number } | null>(null) + const DRAG_START_THRESHOLD = 5 // Pixels to move before counting as drag (not click) + const MAGNIFIER_DISMISS_THRESHOLD = 50 // Pixels to move away from last drag pos to dismiss + // Track whether current target region needs magnification const [targetNeedsMagnification, setTargetNeedsMagnification] = useState(false) @@ -526,6 +535,20 @@ export function MapRenderer({ const [isMobileMapDragging, setIsMobileMapDragging] = useState(false) const mapTouchStartRef = useRef<{ x: number; y: number } | null>(null) const MOBILE_DRAG_THRESHOLD = 10 // pixels before we consider it a drag + // Track if magnifier was triggered by mobile map drag (for showing Select button) + const [mobileMapDragTriggeredMagnifier, setMobileMapDragTriggeredMagnifier] = useState(false) + const AUTO_EXIT_ZOOM_THRESHOLD = 1.5 // Exit expanded mode when zoom drops below this + + // Auto-exit expanded magnifier mode when zoom approaches 1x + useEffect(() => { + if (isMagnifierExpanded && targetZoom < AUTO_EXIT_ZOOM_THRESHOLD) { + console.log( + '[MapRenderer] Auto-exiting expanded magnifier mode - zoom dropped below threshold:', + targetZoom + ) + setIsMagnifierExpanded(false) + } + }, [isMagnifierExpanded, targetZoom]) // Give up reveal animation state const [giveUpFlashProgress, setGiveUpFlashProgress] = useState(0) // 0-1 pulsing value @@ -865,6 +888,12 @@ export function MapRenderer({ // Request pointer lock on first click const handleContainerClick = (e: React.MouseEvent) => { + // If we just finished a drag, suppress this click (user was dragging, not clicking) + if (suppressNextClickRef.current) { + suppressNextClickRef.current = false + return + } + // Silently request pointer lock if not already locked (and supported) // This makes the first gameplay click also enable precision mode // On devices without pointer lock (iPad), skip this and process clicks normally @@ -1784,6 +1813,48 @@ export function MapRenderer({ return guess?.playerId || null } + // Desktop click-and-drag handlers for magnifier + const handleMouseDown = (e: React.MouseEvent) => { + // Only handle left click, and not on touch devices + if (e.button !== 0 || isTouchDevice) return + + let cursorX: number + let cursorY: number + + if (pointerLocked && cursorPositionRef.current) { + // When pointer is locked, use the tracked cursor position + cursorX = cursorPositionRef.current.x + cursorY = cursorPositionRef.current.y + } else { + // Normal mode: use click position + const containerRect = containerRef.current?.getBoundingClientRect() + if (!containerRect) return + + cursorX = e.clientX - containerRect.left + cursorY = e.clientY - containerRect.top + } + + desktopDragStartRef.current = { x: cursorX, y: cursorY } + desktopDragDidMoveRef.current = false + } + + // Track if we should suppress the next click (because user was dragging) + const suppressNextClickRef = useRef(false) + + const handleMouseUp = (e: React.MouseEvent) => { + // If user was dragging, save the last position for threshold-based dismissal + // and suppress the click event that will follow + if (isDesktopMapDragging && cursorPositionRef.current) { + lastDragPositionRef.current = { ...cursorPositionRef.current } + suppressNextClickRef.current = true + } + + // Reset drag state + desktopDragStartRef.current = null + setIsDesktopMapDragging(false) + desktopDragDidMoveRef.current = false + } + // Handle mouse movement to track cursor and show magnifier when needed const handleMouseMove = (e: React.MouseEvent) => { if (!svgRef.current || !containerRef.current) return @@ -1938,10 +2009,61 @@ export function MapRenderer({ // Allow cursor to reach escape threshold at SVG edges cursorX = Math.max(svgOffsetX, Math.min(svgOffsetX + svgRect.width, cursorX)) cursorY = Math.max(svgOffsetY, Math.min(svgOffsetY + svgRect.height, cursorY)) + + // Desktop drag detection in pointer lock mode + // Check if user has moved enough from drag start point + if (desktopDragStartRef.current && !isDesktopMapDragging) { + const deltaX = cursorX - desktopDragStartRef.current.x + const deltaY = cursorY - desktopDragStartRef.current.y + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance >= DRAG_START_THRESHOLD) { + desktopDragDidMoveRef.current = true + setIsDesktopMapDragging(true) + lastDragPositionRef.current = null + } + } + + // Check if cursor has moved far enough from last drag position to dismiss magnifier + if (lastDragPositionRef.current && !isDesktopMapDragging && !shiftPressed) { + const deltaX = cursorX - lastDragPositionRef.current.x + const deltaY = cursorY - lastDragPositionRef.current.y + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance >= MAGNIFIER_DISMISS_THRESHOLD) { + lastDragPositionRef.current = null + } + } } else { // Normal mode: use absolute position cursorX = e.clientX - containerRect.left cursorY = e.clientY - containerRect.top + + // Desktop drag detection: check if user has moved enough from drag start point + if (desktopDragStartRef.current && !isDesktopMapDragging) { + const deltaX = cursorX - desktopDragStartRef.current.x + const deltaY = cursorY - desktopDragStartRef.current.y + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance >= DRAG_START_THRESHOLD) { + desktopDragDidMoveRef.current = true + setIsDesktopMapDragging(true) + // Clear the last drag position since we're starting a new drag + lastDragPositionRef.current = null + } + } + + // Check if cursor has moved far enough from last drag position to dismiss magnifier + // This allows the user to complete a drag and then click without the magnifier disappearing + if (lastDragPositionRef.current && !isDesktopMapDragging && !shiftPressed) { + const deltaX = cursorX - lastDragPositionRef.current.x + const deltaY = cursorY - lastDragPositionRef.current.y + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) + + if (distance >= MAGNIFIER_DISMISS_THRESHOLD) { + lastDragPositionRef.current = null + } + } } // Check if cursor is over the SVG @@ -1983,8 +2105,15 @@ export function MapRenderer({ // 1. Shift key is held down (manual override on desktop) // 2. Current target region needs magnification AND there's a small region nearby // 3. User is dragging on the map on mobile (always show magnifier for mobile drag) + // 4. User is click-dragging on the map on desktop + // 5. User recently finished a drag and cursor is still near the drag end position + const isNearLastDragPosition = lastDragPositionRef.current !== null const shouldShow = - shiftPressed || isMobileMapDragging || (targetNeedsMagnification && hasSmallRegion) + shiftPressed || + isMobileMapDragging || + isDesktopMapDragging || + isNearLastDragPosition || + (targetNeedsMagnification && hasSmallRegion) // Update smallest region size for adaptive cursor dampening // Use hysteresis to prevent rapid flickering at boundaries @@ -2220,6 +2349,12 @@ export function MapRenderer({ return } + // Reset desktop drag state when mouse leaves + desktopDragStartRef.current = null + setIsDesktopMapDragging(false) + desktopDragDidMoveRef.current = false + lastDragPositionRef.current = null + setShowMagnifier(false) setTargetOpacity(0) setCursorPosition(null) @@ -2375,6 +2510,7 @@ export function MapRenderer({ setCursorPosition(null) cursorPositionRef.current = null setIsMagnifierExpanded(false) // Reset expanded state on dismiss + setMobileMapDragTriggeredMagnifier(false) // Reset mobile drag trigger state }, []) const handleMapTouchEnd = useCallback(() => { @@ -2383,6 +2519,8 @@ export function MapRenderer({ if (wasDragging) { setIsMobileMapDragging(false) + // Mark that magnifier was triggered by mobile drag (shows Select button) + setMobileMapDragTriggeredMagnifier(true) // 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 } else if (showMagnifier && cursorPositionRef.current) { @@ -2479,28 +2617,65 @@ export function MapRenderer({ // Update start position for next move magnifierTouchStartRef.current = { x: touch.clientX, y: touch.clientY } - // Scale touch delta by zoom level for 1:1 panning feel. - // - // The magnifier shows a zoomed view of the map. When zoomed 3x: - // - Moving cursor by 1 pixel shifts the magnifier view by 3 pixels - // - To get 1:1 feel (finger moves N pixels = content moves N pixels in magnifier), - // we divide finger movement by zoom level - // - // This makes dragging feel like moving the map under a fixed magnifying glass. - const currentZoom = getCurrentZoom() - const touchMultiplier = 1 / currentZoom + // Get container and SVG measurements first (needed for 1:1 calculation) + const containerRect = containerRef.current.getBoundingClientRect() + const svgRect = svgRef.current.getBoundingClientRect() - // Invert the delta - dragging right on magnifier should show content to the right - // (which means moving the cursor right in the map coordinate space) - // Actually, dragging the "paper" under the magnifier means: + // Calculate viewport scale and magnifier dimensions for true 1:1 panning + // + // For 1:1 panning, we need to account for: + // 1. How the SVG is scaled to fit the container (viewport.scale) + // 2. How the magnifier zooms the content (currentZoom) + // 3. The actual magnifier dimensions + // + // The magnifier shows (viewBoxW / currentZoom) SVG units across magnifierWidth pixels. + // The SVG in the container renders at viewport.scale (container px per SVG unit). + // + // For finger moving N screen pixels to move content N pixels in magnifier: + // - Content movement in SVG units = N / (magnifierWidth * currentZoom / viewBoxW) + // - Cursor movement in container = (N / magnifierScale) * viewport.scale + // - touchMultiplier = viewport.scale * viewBoxW / (magnifierWidth * currentZoom) + const viewBoxParts = displayViewBox.split(' ').map(Number) + const viewBoxW = viewBoxParts[2] || 1000 + const viewBoxH = viewBoxParts[3] || 500 + + // Calculate the viewport scale (how the SVG is scaled to fit the SVG element) + // This is the same calculation as getRenderedViewport but we just need the scale + const svgAspect = viewBoxW / viewBoxH + const containerAspect = svgRect.width / svgRect.height + const viewportScale = + containerAspect > svgAspect + ? svgRect.height / viewBoxH // Height-constrained + : svgRect.width / viewBoxW // Width-constrained + + // Get current magnifier dimensions + const leftoverWidth = containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right + const leftoverHeight = containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom + const { width: magnifierWidth, height: magnifierHeight } = getMagnifierDimensions( + leftoverWidth, + leftoverHeight + ) + const actualMagnifierWidth = isMagnifierExpanded ? leftoverWidth : magnifierWidth + const actualMagnifierHeight = isMagnifierExpanded ? leftoverHeight : magnifierHeight + + const currentZoom = getCurrentZoom() + + // Calculate the true 1:1 touch multiplier + // When finger moves N pixels, content in magnifier should move N pixels visually + // Use the smaller dimension to ensure consistency (magnifier may not be square) + const magnifierScaleX = (actualMagnifierWidth * currentZoom) / viewBoxW + const magnifierScaleY = (actualMagnifierHeight * currentZoom) / viewBoxH + // Use the smaller scale factor to ensure 1:1 feel in the constrained direction + const magnifierScale = Math.min(magnifierScaleX, magnifierScaleY) + const touchMultiplier = viewportScale / magnifierScale + + // Invert the delta - dragging the "paper" under the magnifier means: // - Drag finger right = paper moves right = magnifier shows what was to the LEFT // - So we SUBTRACT the delta to move the cursor in the opposite direction const newCursorX = cursorPositionRef.current.x - deltaX * touchMultiplier const newCursorY = cursorPositionRef.current.y - deltaY * touchMultiplier // Clamp to SVG bounds - const containerRect = containerRef.current.getBoundingClientRect() - const svgRect = svgRef.current.getBoundingClientRect() const svgOffsetX = svgRect.left - containerRect.left const svgOffsetY = svgRect.top - containerRect.top @@ -2511,9 +2686,36 @@ export function MapRenderer({ cursorPositionRef.current = { x: clampedX, y: clampedY } setCursorPosition({ x: clampedX, y: clampedY }) - // Run region detection to update hoveredRegionId (so user sees which region is under cursor) - // We don't update zoom during drag to avoid disorienting zoom changes while panning - const { regionUnderCursor } = detectRegions(clampedX, clampedY) + // Run region detection to update hoveredRegionId and get regions for adaptive zoom + const { + regionUnderCursor, + detectedRegions: detectedRegionObjects, + detectedSmallestSize, + } = detectRegions(clampedX, clampedY) + + // Auto-zoom based on regions at cursor position (same as map drag behavior) + // Filter out found regions from zoom calculations + const unfoundRegionObjects = detectedRegionObjects.filter( + (r) => !regionsFound.includes(r.id) + ) + + // Calculate optimal zoom for the new cursor position + const zoomSearchResult = findOptimalZoom({ + detectedRegions: unfoundRegionObjects, + detectedSmallestSize, + cursorX: clampedX, + cursorY: clampedY, + containerRect, + svgRect, + mapData, + svgElement: svgRef.current, + largestPieceSizesCache: largestPieceSizesRef.current, + maxZoom: MAX_ZOOM, + minZoom: 1, + pointerLocked: false, // Mobile never uses pointer lock + }) + + setTargetZoom(zoomSearchResult.zoom) // Broadcast cursor update to other players (if in multiplayer) if ( @@ -2540,6 +2742,7 @@ export function MapRenderer({ [ isMagnifierDragging, isPinching, + isMagnifierExpanded, getTouchDistance, MAX_ZOOM, setTargetZoom, @@ -2550,6 +2753,8 @@ export function MapRenderer({ localPlayerId, displayViewBox, getCurrentZoom, + regionsFound, + mapData, ] ) @@ -2687,6 +2892,8 @@ export function MapRenderer({
) })()} + + {/* Expand to fullscreen button - top-right corner (mobile only, when not expanded) */} + {!pointerLocked && isTouchDevice && !isMagnifierExpanded && ( + + )} + + {/* Mobile Select button - inside magnifier, bottom-right corner (touch devices only) */} + {isTouchDevice && + mobileMapDragTriggeredMagnifier && + !isMobileMapDragging && + !isMagnifierDragging && + (() => { + // Button is disabled if no region under crosshairs or region already found + const isSelectDisabled = !hoveredRegion || regionsFound.includes(hoveredRegion) + + return ( + + ) + })()} + + {/* Full Map button - inside magnifier, bottom-left corner (touch devices only, when expanded) */} + {isTouchDevice && isMagnifierExpanded && mobileMapDragTriggeredMagnifier && ( + + )} ) })()} - {/* Mobile Select button - appears when magnifier is visible but not being dragged */} - {showMagnifier && !isMobileMapDragging && !isMagnifierDragging && cursorPosition && ( - { - e.stopPropagation() // Prevent triggering map touch end - selectRegionAtCrosshairs() - }} - style={{ - position: 'absolute', - // Position below the magnifier - top: magnifierSpring.top.to((t) => { - const containerRect = containerRef.current?.getBoundingClientRect() - if (!containerRect) return t + 200 - const leftoverWidth = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { height: magnifierHeight } = getMagnifierDimensions( - leftoverWidth, - leftoverHeight - ) - return t + magnifierHeight + 12 // 12px gap below magnifier - }), - left: magnifierSpring.left.to((l) => { - const containerRect = containerRef.current?.getBoundingClientRect() - if (!containerRect) return l - const leftoverWidth = - containerRect.width - SAFE_ZONE_MARGINS.left - SAFE_ZONE_MARGINS.right - const leftoverHeight = - containerRect.height - SAFE_ZONE_MARGINS.top - SAFE_ZONE_MARGINS.bottom - const { width: magnifierWidth } = getMagnifierDimensions( - leftoverWidth, - leftoverHeight - ) - return l + magnifierWidth / 2 - 60 // Center the 120px button under magnifier - }), - width: 120, - opacity: magnifierSpring.opacity, - zIndex: 101, - }} - className={css({ - padding: '12px 24px', - background: 'linear-gradient(135deg, #22c55e, #16a34a)', - border: 'none', - borderRadius: '12px', - color: 'white', - fontSize: '16px', - fontWeight: 'bold', - cursor: 'pointer', - boxShadow: '0 4px 12px rgba(34, 197, 94, 0.4)', - touchAction: 'none', - _active: { - transform: 'scale(0.95)', - }, - })} - > - Select ✓ - - )} - {/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */} {(() => { if (!showMagnifier || !cursorPosition || !svgRef.current || !containerRef.current) { diff --git a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx index 138dc7e3..c72a03d0 100644 --- a/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/PlayingPhase.tsx @@ -6,7 +6,6 @@ import { useGameMode, useViewerId } from '@/lib/arcade/game-sdk' import { getAssistanceLevel, getFilteredMapDataBySizesSync } from '../maps' import { CROP_UPDATE_EVENT, CROP_MODE_EVENT, type CropModeEventDetail } from '../customCrops' import { useKnowYourWorld } from '../Provider' -import { MusicControlPanel } from '../music' import { GameInfoPanel } from './GameInfoPanel' import { MapRenderer } from './MapRenderer' @@ -175,19 +174,16 @@ export function PlayingPhase() { {/* Floating Game Info UI - hidden during crop mode to allow unobstructed dragging */} {!cropModeActive && ( - <> - - - + )}
) diff --git a/apps/web/src/arcade-games/know-your-world/music/MusicControlModal.tsx b/apps/web/src/arcade-games/know-your-world/music/MusicControlModal.tsx new file mode 100644 index 00000000..bbe89084 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/music/MusicControlModal.tsx @@ -0,0 +1,448 @@ +/** + * Music Control Modal + * + * A modal dialog for music controls accessible from the guidance menu. + * Shows play/stop, volume, and optionally debug info. + */ + +'use client' + +import { useState } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { css } from '@styled/css' +import { useTheme } from '@/contexts/ThemeContext' +import { useMusic } from './MusicContext' + +// Map region IDs to country names for display +const regionNames: Record = { + // Europe + fr: 'France', + es: 'Spain', + it: 'Italy', + ie: 'Ireland', + de: 'Germany', + gr: 'Greece', + ru: 'Russia', + gb: 'United Kingdom', + pt: 'Portugal', + nl: 'Netherlands', + // Asia + jp: 'Japan', + cn: 'China', + in: 'India', + kr: 'South Korea', + th: 'Thailand', + vn: 'Vietnam', + // Americas + br: 'Brazil', + mx: 'Mexico', + ar: 'Argentina', + cu: 'Cuba', + jm: 'Jamaica', + // Africa + ng: 'Nigeria', + gh: 'Ghana', + ke: 'Kenya', + eg: 'Egypt', + // Middle East + tr: 'Turkey', + sa: 'Saudi Arabia', + ae: 'UAE', + // Oceania + au: 'Australia', + nz: 'New Zealand', + // USA states + la: 'Louisiana', + tn: 'Tennessee', + tx: 'Texas', + ny: 'New York', + ca: 'California', + hi: 'Hawaii', +} + +// Map temperature to descriptive text +function getTemperatureDescription(temp: string | null): string | null { + if (!temp) return null + switch (temp) { + case 'on_fire': + case 'hot': + return 'intensifying' + case 'warmer': + return 'warming up' + case 'colder': + return 'cooling down' + case 'cold': + case 'freezing': + return 'distant' + default: + return null + } +} + +interface MusicControlModalProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function MusicControlModal({ open, onOpenChange }: MusicControlModalProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === 'dark' + const music = useMusic() + const [isDebugExpanded, setIsDebugExpanded] = useState(false) + + // Build music description + const buildDescription = (): string => { + const parts: string[] = [] + + // Base continent/region + if (music.currentPresetName && music.currentPresetName !== 'Default') { + parts.push(`${music.currentPresetName} style`) + } else { + parts.push('Adventure theme') + } + + // Hyper-local hint + if (music.isHintActive && music.hintRegionId) { + const regionName = regionNames[music.hintRegionId.toLowerCase()] || music.hintRegionId + parts.push(`with ${regionName} hint`) + } + + // Temperature effect + const tempDesc = getTemperatureDescription(music.currentTemperature) + if (tempDesc) { + parts.push(`(${tempDesc})`) + } + + return parts.join(' ') + } + + return ( + + + + + + đŸŽĩ + Music Controls + + + {/* Play/Stop button */} + + + {/* Music description */} +
+ {music.isPlaying ? buildDescription() : 'Music paused'} +
+ + {/* Volume control */} +
+ 🔈 + music.setVolume(parseFloat(e.target.value))} + min={0} + max={1} + step={0.05} + className={css({ + flex: 1, + height: '6px', + appearance: 'none', + bg: isDark ? 'gray.600' : 'gray.300', + borderRadius: '9999px', + cursor: 'pointer', + '&::-webkit-slider-thumb': { + appearance: 'none', + width: '18px', + height: '18px', + bg: isDark ? 'blue.400' : 'blue.500', + borderRadius: '50%', + cursor: 'pointer', + }, + '&::-moz-range-thumb': { + width: '18px', + height: '18px', + bg: isDark ? 'blue.400' : 'blue.500', + borderRadius: '50%', + border: 'none', + cursor: 'pointer', + }, + _focus: { + outline: 'none', + }, + })} + /> + 🔊 + + {Math.round(music.volume * 100)}% + +
+ + {/* Debug toggle */} + + + {/* Debug panel */} + {isDebugExpanded && ( +
+ {/* Status info */} +
+ Preset: + + {music.currentPresetId} + + Hint: + + {music.isHintActive ? music.hintRegionId : 'none'} + + Temp: + + {music.currentTemperature || 'neutral'} + +
+ + {/* Pattern code */} +
+
+                  {music.currentPattern || '// No pattern loaded'}
+                
+
+ + {/* Copy button */} + +
+ )} + + {/* Close button */} + + ✕ + +
+
+
+ ) +} diff --git a/apps/web/src/arcade-games/know-your-world/music/MusicControlPanel.tsx b/apps/web/src/arcade-games/know-your-world/music/MusicControlPanel.tsx index 392c7036..708183ea 100644 --- a/apps/web/src/arcade-games/know-your-world/music/MusicControlPanel.tsx +++ b/apps/web/src/arcade-games/know-your-world/music/MusicControlPanel.tsx @@ -111,30 +111,33 @@ export function MusicControlPanel() { return parts.join(' ') } - // Show minimal "Enable Music" button when not initialized - const showEnableButton = !music.isInitialized && music.isMuted + // Show compact button when music is not playing + // This keeps the panel minimal when stopped, reducing UI clutter on narrow screens + const showCompactButton = !music.isPlaying return (
- {/* Show enable button when music not initialized */} - {showEnableButton ? ( + {/* Show compact button when music is not playing */} + {showCompactButton ? ( ) : ( <> diff --git a/apps/web/src/arcade-games/know-your-world/music/index.ts b/apps/web/src/arcade-games/know-your-world/music/index.ts index 2038130e..c03d9a55 100644 --- a/apps/web/src/arcade-games/know-your-world/music/index.ts +++ b/apps/web/src/arcade-games/know-your-world/music/index.ts @@ -8,6 +8,7 @@ export { useMusicEngine, type MusicEngine } from './useMusicEngine' export { MusicProvider, useMusic, useMusicOptional } from './MusicContext' export { MusicControls } from './MusicControls' export { MusicControlPanel } from './MusicControlPanel' +export { MusicControlModal } from './MusicControlModal' export { continentalPresets, getPreset, diff --git a/apps/web/src/components/MyAbacus.tsx b/apps/web/src/components/MyAbacus.tsx index 9f5c68f7..4a1f8ce7 100644 --- a/apps/web/src/components/MyAbacus.tsx +++ b/apps/web/src/components/MyAbacus.tsx @@ -9,7 +9,7 @@ import { HomeHeroContext } from '@/contexts/HomeHeroContext' import { useTheme } from '@/contexts/ThemeContext' export function MyAbacus() { - const { isOpen, close, toggle, isHidden } = useMyAbacus() + const { isOpen, close, toggle, isHidden, showInGame } = useMyAbacus() const appConfig = useAbacusConfig() const pathname = usePathname() const { resolvedTheme } = useTheme() @@ -63,10 +63,15 @@ export function MyAbacus() { const structuralStyles = ABACUS_THEMES.light const trophyStyles = ABACUS_THEMES.trophy - // Hide completely when isHidden is true (e.g., virtual keyboard is shown) + // Detect if we're on a game route (arcade games hide the abacus by default) + const isOnGameRoute = pathname?.startsWith('/arcade/') + + // Hide completely when: + // 1. isHidden is true (e.g., virtual keyboard is shown on non-game pages) + // 2. On a game route and the game hasn't opted in to show it // Still allow open state to work (user explicitly opened it) // NOTE: This must come after all hooks to follow React's rules of hooks - if (isHidden && !isOpen) { + if (!isOpen && (isHidden || (isOnGameRoute && !showInGame))) { return null } diff --git a/apps/web/src/contexts/MyAbacusContext.tsx b/apps/web/src/contexts/MyAbacusContext.tsx index 9443c56f..c7b5e4c8 100644 --- a/apps/web/src/contexts/MyAbacusContext.tsx +++ b/apps/web/src/contexts/MyAbacusContext.tsx @@ -11,6 +11,9 @@ interface MyAbacusContextValue { /** Temporarily hide the abacus (e.g., when virtual keyboard is shown) */ isHidden: boolean setIsHidden: (hidden: boolean) => void + /** Opt-in to show the abacus while in a game (games hide it by default) */ + showInGame: boolean + setShowInGame: (show: boolean) => void } const MyAbacusContext = createContext(undefined) @@ -18,13 +21,16 @@ const MyAbacusContext = createContext(undefine export function MyAbacusProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false) const [isHidden, setIsHidden] = useState(false) + const [showInGame, setShowInGame] = useState(false) const open = useCallback(() => setIsOpen(true), []) const close = useCallback(() => setIsOpen(false), []) const toggle = useCallback(() => setIsOpen((prev) => !prev), []) return ( - + {children} )