feat(know-your-world): improve magnifier UX and hide abacus on games
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 <noreply@anthropic.com>
This commit is contained in:
parent
c502a4fa92
commit
fa1514d351
|
|
@ -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 */}
|
||||
<DropdownMenu.Separator
|
||||
className={css({
|
||||
height: '1px',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
margin: '1 0',
|
||||
})}
|
||||
/>
|
||||
<DropdownMenu.Item
|
||||
data-action="open-music-settings"
|
||||
onSelect={() => 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',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{music.isPlaying ? '🎵' : '🔇'}</span>
|
||||
<span>Music Settings</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
|
@ -1317,6 +1336,9 @@ export function GameInfoPanel({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Music Control Modal */}
|
||||
<MusicControlModal open={isMusicModalOpen} onOpenChange={setIsMusicModalOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -506,6 +506,15 @@ export function MapRenderer({
|
|||
const [smallestRegionSize, setSmallestRegionSize] = useState<number>(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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>) => {
|
||||
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({
|
|||
<div
|
||||
ref={containerRef}
|
||||
data-component="map-renderer"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleContainerClick}
|
||||
|
|
@ -4164,73 +4371,155 @@ export function MapRenderer({
|
|||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Expand to fullscreen button - top-right corner (mobile only, when not expanded) */}
|
||||
{!pointerLocked && isTouchDevice && !isMagnifierExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-magnifier-fullscreen"
|
||||
onTouchStart={(e) => {
|
||||
// Stop touch events from bubbling to magnifier handlers
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
// Stop touch events and handle the action
|
||||
e.stopPropagation()
|
||||
e.preventDefault() // Prevent click event from also firing
|
||||
setIsMagnifierExpanded(true)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Fallback for non-touch interactions
|
||||
e.stopPropagation()
|
||||
setIsMagnifierExpanded(true)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: isDark ? 'rgba(31, 41, 55, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
color: isDark ? '#60a5fa' : '#3b82f6',
|
||||
fontSize: '14px',
|
||||
padding: 0,
|
||||
}}
|
||||
title="Expand to fullscreen"
|
||||
>
|
||||
{/* Expand icon (arrows pointing outward) */}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<polyline points="9 21 3 21 3 15" />
|
||||
<line x1="21" y1="3" x2="14" y2="10" />
|
||||
<line x1="3" y1="21" x2="10" y2="14" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-select-region"
|
||||
disabled={isSelectDisabled}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (!isSelectDisabled) selectRegionAtCrosshairs()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isSelectDisabled) selectRegionAtCrosshairs()
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
padding: '10px 20px',
|
||||
background: isSelectDisabled
|
||||
? 'linear-gradient(135deg, #9ca3af, #6b7280)'
|
||||
: 'linear-gradient(135deg, #22c55e, #16a34a)',
|
||||
border: 'none',
|
||||
borderTopLeftRadius: '12px',
|
||||
borderBottomRightRadius: '10px', // Match magnifier border radius minus border
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: isSelectDisabled ? 'not-allowed' : 'pointer',
|
||||
touchAction: 'none',
|
||||
boxShadow: isSelectDisabled
|
||||
? 'inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: 'inset 0 1px 0 rgba(255,255,255,0.3)',
|
||||
opacity: isSelectDisabled ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Full Map button - inside magnifier, bottom-left corner (touch devices only, when expanded) */}
|
||||
{isTouchDevice && isMagnifierExpanded && mobileMapDragTriggeredMagnifier && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="exit-magnifier-fullscreen"
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setIsMagnifierExpanded(false)
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsMagnifierExpanded(false)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
padding: '10px 20px',
|
||||
background: 'linear-gradient(135deg, #6b7280, #4b5563)',
|
||||
border: 'none',
|
||||
borderTopRightRadius: '12px',
|
||||
borderBottomLeftRadius: '10px', // Match magnifier border radius minus border
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
touchAction: 'none',
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
Full Map
|
||||
</button>
|
||||
)}
|
||||
</animated.div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Mobile Select button - appears when magnifier is visible but not being dragged */}
|
||||
{showMagnifier && !isMobileMapDragging && !isMagnifierDragging && cursorPosition && (
|
||||
<animated.button
|
||||
data-action="mobile-select-region"
|
||||
type="button"
|
||||
onClick={selectRegionAtCrosshairs}
|
||||
onTouchEnd={(e) => {
|
||||
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 ✓
|
||||
</animated.button>
|
||||
)}
|
||||
|
||||
{/* Zoom lines connecting indicator to magnifier - creates "pop out" effect */}
|
||||
{(() => {
|
||||
if (!showMagnifier || !cursorPosition || !svgRef.current || !containerRef.current) {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
<GameInfoPanel
|
||||
mapData={mapData}
|
||||
currentRegionName={currentRegionName}
|
||||
currentRegionId={currentRegionId}
|
||||
selectedMap={state.selectedMap}
|
||||
foundCount={foundCount}
|
||||
totalRegions={totalRegions}
|
||||
progress={progress}
|
||||
onHintsUnlock={handleHintsUnlock}
|
||||
/>
|
||||
<MusicControlPanel />
|
||||
</>
|
||||
<GameInfoPanel
|
||||
mapData={mapData}
|
||||
currentRegionName={currentRegionName}
|
||||
currentRegionId={currentRegionId}
|
||||
selectedMap={state.selectedMap}
|
||||
foundCount={foundCount}
|
||||
totalRegions={totalRegions}
|
||||
progress={progress}
|
||||
onHintsUnlock={handleHintsUnlock}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
// 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 (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
data-element="music-modal-overlay"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 9998,
|
||||
animation: 'fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
})}
|
||||
/>
|
||||
<Dialog.Content
|
||||
data-component="music-control-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
borderRadius: 'xl',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
padding: '5',
|
||||
width: '90vw',
|
||||
maxWidth: '360px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
zIndex: 9999,
|
||||
animation: 'contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<Dialog.Title
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
})}
|
||||
>
|
||||
<span>🎵</span>
|
||||
Music Controls
|
||||
</Dialog.Title>
|
||||
|
||||
{/* Play/Stop button */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (music.isPlaying) {
|
||||
music.disableMusic()
|
||||
} else {
|
||||
await music.enableMusic()
|
||||
}
|
||||
}}
|
||||
data-action="toggle-music"
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '3',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'medium',
|
||||
cursor: 'pointer',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
transition: 'all 0.15s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2',
|
||||
bg: music.isPlaying
|
||||
? isDark
|
||||
? 'red.900/50'
|
||||
: 'red.100'
|
||||
: isDark
|
||||
? 'green.900/50'
|
||||
: 'green.100',
|
||||
color: music.isPlaying
|
||||
? isDark
|
||||
? 'red.300'
|
||||
: 'red.700'
|
||||
: isDark
|
||||
? 'green.300'
|
||||
: 'green.700',
|
||||
borderColor: music.isPlaying
|
||||
? isDark
|
||||
? 'red.700'
|
||||
: 'red.300'
|
||||
: isDark
|
||||
? 'green.700'
|
||||
: 'green.300',
|
||||
_hover: {
|
||||
bg: music.isPlaying
|
||||
? isDark
|
||||
? 'red.800/50'
|
||||
: 'red.200'
|
||||
: isDark
|
||||
? 'green.800/50'
|
||||
: 'green.200',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{music.isPlaying ? '⏹️' : '▶️'}</span>
|
||||
<span>
|
||||
{music.isPlaying ? 'Stop Music' : music.isInitialized ? 'Play Music' : 'Enable Music'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Music description */}
|
||||
<div
|
||||
data-element="music-description"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
marginTop: '3',
|
||||
marginBottom: '4',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{music.isPlaying ? buildDescription() : 'Music paused'}
|
||||
</div>
|
||||
|
||||
{/* Volume control */}
|
||||
<div
|
||||
data-element="volume-control"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
padding: '3',
|
||||
bg: isDark ? 'gray.900/50' : 'gray.100',
|
||||
rounded: 'lg',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'lg' })}>🔈</span>
|
||||
<input
|
||||
type="range"
|
||||
data-element="volume-slider"
|
||||
value={music.volume}
|
||||
onChange={(e) => 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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'lg' })}>🔊</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
minWidth: '40px',
|
||||
textAlign: 'right',
|
||||
})}
|
||||
>
|
||||
{Math.round(music.volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Debug toggle */}
|
||||
<button
|
||||
onClick={() => setIsDebugExpanded(!isDebugExpanded)}
|
||||
data-action="toggle-debug"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
marginTop: '4',
|
||||
padding: '2',
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{isDebugExpanded ? '▼' : '▶'}</span>
|
||||
<span>Debug Info</span>
|
||||
</button>
|
||||
|
||||
{/* Debug panel */}
|
||||
{isDebugExpanded && (
|
||||
<div
|
||||
data-element="debug-panel"
|
||||
className={css({
|
||||
marginTop: '2',
|
||||
padding: '3',
|
||||
bg: isDark ? 'gray.900' : 'gray.100',
|
||||
rounded: 'lg',
|
||||
fontSize: 'xs',
|
||||
})}
|
||||
>
|
||||
{/* Status info */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto 1fr',
|
||||
gap: '1 2',
|
||||
marginBottom: '3',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
<span>Preset:</span>
|
||||
<span className={css({ color: isDark ? 'blue.400' : 'blue.600' })}>
|
||||
{music.currentPresetId}
|
||||
</span>
|
||||
<span>Hint:</span>
|
||||
<span className={css({ color: isDark ? 'green.400' : 'green.600' })}>
|
||||
{music.isHintActive ? music.hintRegionId : 'none'}
|
||||
</span>
|
||||
<span>Temp:</span>
|
||||
<span className={css({ color: isDark ? 'orange.400' : 'orange.600' })}>
|
||||
{music.currentTemperature || 'neutral'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pattern code */}
|
||||
<div
|
||||
className={css({
|
||||
bg: isDark ? 'gray.950' : 'white',
|
||||
rounded: 'md',
|
||||
padding: '2',
|
||||
maxHeight: '120px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.300',
|
||||
})}
|
||||
>
|
||||
<pre
|
||||
className={css({
|
||||
fontFamily: 'mono',
|
||||
fontSize: '10px',
|
||||
lineHeight: '1.4',
|
||||
color: isDark ? 'green.300' : 'green.700',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
{music.currentPattern || '// No pattern loaded'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(music.currentPattern)
|
||||
}}
|
||||
data-action="copy-pattern"
|
||||
className={css({
|
||||
marginTop: '2',
|
||||
padding: '1.5 3',
|
||||
fontSize: 'xs',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
rounded: 'md',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Copy to clipboard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<Dialog.Close
|
||||
data-action="close-music-modal"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '3',
|
||||
right: '3',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'md',
|
||||
padding: '2',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
cursor: 'pointer',
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.700' : 'gray.100',
|
||||
color: isDark ? 'gray.200' : 'gray.900',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✕
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
data-component="music-control-panel"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: { base: '130px', sm: '150px' },
|
||||
// On mobile, position below the prompt box which is ~130px from top + ~150px tall
|
||||
// On larger screens, position alongside the prompt box
|
||||
top: { base: '290px', sm: '150px' },
|
||||
right: { base: '2', sm: '4' },
|
||||
zIndex: 50,
|
||||
padding: showEnableButton ? '2 3' : '3',
|
||||
padding: showCompactButton ? '2 3' : '3',
|
||||
bg: isDark ? 'gray.800/90' : 'white/90',
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.300',
|
||||
rounded: 'xl',
|
||||
shadow: 'lg',
|
||||
minWidth: showEnableButton ? '140px' : '200px',
|
||||
minWidth: showCompactButton ? '140px' : '200px',
|
||||
maxWidth: '320px',
|
||||
})}
|
||||
>
|
||||
{/* Show enable button when music not initialized */}
|
||||
{showEnableButton ? (
|
||||
{/* Show compact button when music is not playing */}
|
||||
{showCompactButton ? (
|
||||
<button
|
||||
onClick={() => music.enableMusic()}
|
||||
data-action="enable-music"
|
||||
|
|
@ -159,7 +162,7 @@ export function MusicControlPanel() {
|
|||
})}
|
||||
>
|
||||
<span>🎵</span>
|
||||
<span>Enable Music</span>
|
||||
<span>{music.isInitialized ? 'Play Music' : 'Enable Music'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MyAbacusContextValue | undefined>(undefined)
|
||||
|
|
@ -18,13 +21,16 @@ const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(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 (
|
||||
<MyAbacusContext.Provider value={{ isOpen, open, close, toggle, isHidden, setIsHidden }}>
|
||||
<MyAbacusContext.Provider
|
||||
value={{ isOpen, open, close, toggle, isHidden, setIsHidden, showInGame, setShowInGame }}
|
||||
>
|
||||
{children}
|
||||
</MyAbacusContext.Provider>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue