revert(know-your-world): undo premature extractions, restore working state

Reverts previous extraction attempts that went down a problematic path:
- Remove DebugAutoZoomPanel component
- Remove OtherPlayerCursors component
- Remove useUserPreferences hook
- Restore inline implementations in MapRenderer

MapRenderer is at 3912 lines. Will proceed with a more careful
extraction strategy based on updated deep dive analysis.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-03 08:39:41 -06:00
parent 022ee0256a
commit f0bf2050d3
19 changed files with 1078 additions and 1301 deletions

View File

@ -2,7 +2,7 @@
**Last Updated:** December 2024
## Current State: 3593 lines (was 4679)
## Current State: 3912 lines (was 4679)
### Progress So Far
- ✅ CompassCrosshair extracted (~80 lines)
@ -12,11 +12,8 @@
- ✅ CursorOverlay extracted (~104 lines)
- ✅ hotColdStyles utilities extracted (~234 lines)
- ✅ RegionLayer extracted (~119 lines)
- ✅ OtherPlayerCursors extracted (~148 lines) - `features/multiplayer/`
- ✅ DebugAutoZoomPanel extracted (~128 lines) - `features/debug/`
- ✅ useUserPreferences hook extracted (~43 lines) - `features/user-preferences/`
**Total reduction: 1086 lines (23.2%)**
**Total reduction: 767 lines (16.4%)**
### Hook/State Explosion
@ -348,11 +345,11 @@ Lines 3278-3548. The magnifier is already using extracted components internally
### Recommended Priority
1. ~~**OtherPlayerCursors** - 5 min, -145 lines~~ ✅ DONE (-148 lines)
2. ~~**DebugAutoZoomPanel** - 10 min, -123 lines~~ ✅ DONE (-128 lines)
3. ~~**useUserPreferences** - 15 min, -90 lines~~ ✅ DONE (-43 lines)
4. **useCelebration** - SKIPPED (too intertwined with click handling, puzzle piece animation, assistance levels)
5. **useMapInteraction** - 2 hours, -600 lines (biggest impact but most complex) - FUTURE WORK
1. **OtherPlayerCursors** - 5 min, -145 lines
2. **DebugAutoZoomPanel** - 10 min, -123 lines
3. **useUserPreferences** - 15 min, -90 lines
4. **useCelebration** - 20 min, -130 lines
5. **useMapInteraction** - 2 hours, -600 lines (biggest impact but most complex)
### Target Architecture

View File

@ -1,199 +0,0 @@
/**
* DebugAutoZoomPanel Component
*
* Debug visualization for the auto-zoom detection system.
* Shows detected regions, zoom decisions, and threshold information.
* Only visible when debug mode is enabled.
*/
'use client'
import { memo } from 'react'
import { useMapRendererContext } from '../map-renderer'
import type { findOptimalZoom } from '../../utils/adaptiveZoomSearch'
// ============================================================================
// Types
// ============================================================================
export interface DetectedRegion {
id: string
pixelWidth: number
pixelHeight: number
isVerySmall: boolean
}
export interface DetectionResult {
detectedRegions: DetectedRegion[]
hasSmallRegion: boolean
detectedSmallestSize: number
}
export interface DebugAutoZoomPanelProps {
/** Whether panel is visible */
visible: boolean
/** Magnifier left position (for positioning panel on opposite side) */
magnifierLeft: number
/** Function to detect regions at cursor position */
detectRegions: (x: number, y: number) => DetectionResult
/** Function to get current zoom level */
getCurrentZoom: () => number
/** Target zoom level */
targetZoom: number
/** Debug info from zoom search algorithm */
zoomSearchDebugInfo: ReturnType<typeof findOptimalZoom> | null
}
// ============================================================================
// Component
// ============================================================================
/**
* Renders debug visualization for auto-zoom detection.
* Shows a detection box around cursor and detailed information panel.
*/
export const DebugAutoZoomPanel = memo(function DebugAutoZoomPanel({
visible,
magnifierLeft,
detectRegions,
getCurrentZoom,
targetZoom,
zoomSearchDebugInfo,
}: DebugAutoZoomPanelProps) {
// Get shared state from context
const { cursorPosition, containerRef } = useMapRendererContext()
if (!visible || !cursorPosition || !containerRef.current) return null
const { detectedRegions, hasSmallRegion, detectedSmallestSize } = detectRegions(
cursorPosition.x,
cursorPosition.y
)
// Position on opposite side from magnifier
const containerWidth = containerRef.current.getBoundingClientRect().width
const magnifierOnLeft = magnifierLeft < containerWidth / 2
return (
<>
{/* Detection box - 50px box around cursor */}
<div
data-element="debug-detection-box"
style={{
position: 'absolute',
left: `${cursorPosition.x - 25}px`,
top: `${cursorPosition.y - 25}px`,
width: '50px',
height: '50px',
border: '2px dashed yellow',
pointerEvents: 'none',
zIndex: 150,
}}
/>
{/* Detection info overlay - opposite side from magnifier */}
<div
data-element="debug-auto-zoom-panel"
style={{
position: 'absolute',
bottom: '10px',
left: magnifierOnLeft ? undefined : '10px',
right: magnifierOnLeft ? '10px' : undefined,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace',
pointerEvents: 'none',
zIndex: 150,
maxWidth: '300px',
}}
>
<div>
<strong>Detection Box (50px)</strong>
</div>
<div>Regions detected: {detectedRegions.length}</div>
<div>Has small region: {hasSmallRegion ? 'YES' : 'NO'}</div>
<div>
Smallest size:{' '}
{detectedSmallestSize === Infinity ? '∞' : `${detectedSmallestSize.toFixed(1)}px`}
</div>
{/* Zoom Decision Details */}
{zoomSearchDebugInfo && (
<>
<div
style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #444',
}}
>
<strong>Zoom Decision:</strong>
</div>
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
Final zoom: <strong>{zoomSearchDebugInfo.zoom.toFixed(1)}×</strong>
{!zoomSearchDebugInfo.foundGoodZoom && ' (fallback to min)'}
</div>
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
Accepted: <strong>{zoomSearchDebugInfo.acceptedRegionId || 'none'}</strong>
</div>
<div style={{ fontSize: '10px', marginLeft: '8px' }}>
Thresholds: {(zoomSearchDebugInfo.acceptanceThresholds.min * 100).toFixed(0)}% -{' '}
{(zoomSearchDebugInfo.acceptanceThresholds.max * 100).toFixed(0)}% of magnifier
</div>
<div style={{ marginTop: '8px' }}>
<strong>Region Analysis (top 3):</strong>
</div>
{Array.from(
new Map(zoomSearchDebugInfo.regionDecisions.map((d) => [d.regionId, d])).values()
)
.sort((a, b) => b.importance - a.importance)
.slice(0, 3)
.map((decision) => {
const marker = decision.wasAccepted ? '✓' : '✗'
const color = decision.wasAccepted ? '#0f0' : '#888'
return (
<div
key={`decision-${decision.regionId}`}
style={{
fontSize: '9px',
marginLeft: '8px',
color,
}}
>
{marker} {decision.regionId}: {decision.currentSize.width.toFixed(0)}×
{decision.currentSize.height.toFixed(0)}px
{decision.rejectionReason && ` (${decision.rejectionReason})`}
</div>
)
})}
</>
)}
<div
style={{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #444',
}}
>
<strong>Detected Regions ({detectedRegions.length}):</strong>
</div>
{detectedRegions.map((region) => (
<div key={region.id} style={{ fontSize: '10px', marginLeft: '8px' }}>
{region.id}: {region.pixelWidth.toFixed(1)}×{region.pixelHeight.toFixed(1)}px
{region.isVerySmall ? ' (SMALL)' : ''}
</div>
))}
<div style={{ marginTop: '8px' }}>
<strong>Current Zoom:</strong> {getCurrentZoom().toFixed(1)}×
</div>
<div>
<strong>Target Zoom:</strong> {targetZoom.toFixed(1)}×
</div>
</div>
</>
)
})

View File

@ -4,13 +4,6 @@
* Debug panels and visualization tools for MapRenderer development.
*/
export type {
DebugAutoZoomPanelProps,
DetectedRegion,
DetectionResult,
} from './DebugAutoZoomPanel'
export { DebugAutoZoomPanel } from './DebugAutoZoomPanel'
export type { HotColdDebugPanelProps } from './HotColdDebugPanel'
export { HotColdDebugPanel } from './HotColdDebugPanel'

View File

@ -9,7 +9,7 @@
*/
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
import { type RefObject, useEffect, useState } from 'react'
import { RefObject, useEffect, useState } from 'react'
import type { MapData, MapRegion } from '../../types'
import { getArrowStartPoint, getRenderedViewport } from './labelUtils'

View File

@ -90,7 +90,15 @@ export const LetterDisplay = memo(function LetterDisplay({
const isSpace = char === ' '
if (isSpace) {
return <StyledLetter key={index} char={char} status="space" isDark={isDark} index={index} />
return (
<StyledLetter
key={index}
char={char}
status="space"
isDark={isDark}
index={index}
/>
)
}
// Get current index before incrementing
@ -105,7 +113,15 @@ export const LetterDisplay = memo(function LetterDisplay({
isComplete
)
return <StyledLetter key={index} char={char} status={status} isDark={isDark} index={index} />
return (
<StyledLetter
key={index}
char={char}
status={status}
isDark={isDark}
index={index}
/>
)
})
}, [regionName, confirmedCount, requiredLetters, isComplete, isDark])

View File

@ -89,7 +89,10 @@ export function getLetterStatus(
* @param isDark - Whether dark mode is active
* @returns CSS properties for the letter
*/
export function getLetterStyles(status: LetterStatus, isDark: boolean): React.CSSProperties {
export function getLetterStyles(
status: LetterStatus,
isDark: boolean
): React.CSSProperties {
const baseStyles: React.CSSProperties = {
transition: 'all 0.15s ease-out',
}
@ -126,7 +129,10 @@ export function getLetterStyles(status: LetterStatus, isDark: boolean): React.CS
* @param requiredLetters - Number of letters required
* @returns Progress value (0 = none, 1 = complete)
*/
export function calculateProgress(confirmedCount: number, requiredLetters: number): number {
export function calculateProgress(
confirmedCount: number,
requiredLetters: number
): number {
if (requiredLetters === 0) return 1
return Math.min(1, confirmedCount / requiredLetters)
}

View File

@ -91,7 +91,12 @@ export function useLetterConfirmation({
// Get letter status for display
const getLetterStatus = useCallback(
(nonSpaceIndex: number): LetterStatus => {
return getLetterStatusUtil(nonSpaceIndex, confirmedCount, requiredLetters, isComplete)
return getLetterStatusUtil(
nonSpaceIndex,
confirmedCount,
requiredLetters,
isComplete
)
},
[confirmedCount, requiredLetters, isComplete]
)
@ -104,7 +109,10 @@ export function useLetterConfirmation({
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input or textarea
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return
}

View File

@ -211,7 +211,11 @@ export const MagnifierControls = memo(function MagnifierControls({
<>
{/* Expand button - top-right corner (when not expanded and not pointer locked) */}
{!isExpanded && !pointerLocked && (
<ControlButton position="top-right" style="icon" onClick={onExpand}>
<ControlButton
position="top-right"
style="icon"
onClick={onExpand}
>
<ExpandIcon isDark={isDark} />
</ControlButton>
)}
@ -230,7 +234,11 @@ export const MagnifierControls = memo(function MagnifierControls({
{/* Full Map button - bottom-left corner (when expanded) */}
{isExpanded && showSelectButton && (
<ControlButton position="bottom-left" style="secondary" onClick={onExitExpanded}>
<ControlButton
position="bottom-left"
style="secondary"
onClick={onExitExpanded}
>
Full Map
</ControlButton>
)}

View File

@ -50,12 +50,7 @@ export interface MagnifierRegionsProps {
/** Get player ID who found a region */
getPlayerWhoFoundRegion: (regionId: string) => string | null
/** Get region fill color */
getRegionColor: (
regionId: string,
isFound: boolean,
isHovered: boolean,
isDark: boolean
) => string
getRegionColor: (regionId: string, isFound: boolean, isHovered: boolean, isDark: boolean) => string
/** Get region stroke color */
getRegionStroke: (isFound: boolean, isDark: boolean) => string
/** Whether to show region outline */
@ -85,8 +80,13 @@ export const MagnifierRegions = memo(function MagnifierRegions({
getRegionStroke,
showOutline,
}: MagnifierRegionsProps) {
const { regionsFound, hoveredRegion, celebrationRegionId, giveUpRegionId, isGiveUpAnimating } =
regionState
const {
regionsFound,
hoveredRegion,
celebrationRegionId,
giveUpRegionId,
isGiveUpAnimating,
} = regionState
const { celebrationFlash, giveUpFlash } = flashProgress
return (

View File

@ -16,10 +16,7 @@
import { memo, useMemo } from 'react'
import { getRenderedViewport } from '../labels'
import {
getAdjustedMagnifiedDimensions,
getMagnifierDimensions,
} from '../../utils/magnifierDimensions'
import { getAdjustedMagnifiedDimensions, getMagnifierDimensions } from '../../utils/magnifierDimensions'
// ============================================================================
// Types
@ -127,7 +124,12 @@ export const ZoomLines = memo(function ZoomLines({
isDark,
}: ZoomLinesProps) {
// Memoize all the calculated values
const { paths, visibleCorners, lineColor, glowColor } = useMemo(() => {
const {
paths,
visibleCorners,
lineColor,
glowColor,
} = useMemo(() => {
// Calculate leftover rectangle dimensions (area not covered by UI elements)
const leftoverWidth = containerRect.width - safeZoneMargins.left - safeZoneMargins.right
const leftoverHeight = containerRect.height - safeZoneMargins.top - safeZoneMargins.bottom
@ -208,7 +210,14 @@ export const ZoomLines = memo(function ZoomLines({
magTop + magnifierHeight
)
// Check if line passes through indicator
const passesThroughInd = linePassesThroughRect(from, to, indTL.x, indTL.y, indBR.x, indBR.y)
const passesThroughInd = linePassesThroughRect(
from,
to,
indTL.x,
indTL.y,
indBR.x,
indBR.y
)
return !passesThroughMag && !passesThroughInd
})
@ -224,7 +233,9 @@ export const ZoomLines = memo(function ZoomLines({
: isDark
? '#60a5fa'
: '#3b82f6' // blue
const calculatedGlowColor = isHighZoom ? 'rgba(251, 191, 36, 0.6)' : 'rgba(96, 165, 250, 0.6)'
const calculatedGlowColor = isHighZoom
? 'rgba(251, 191, 36, 0.6)'
: 'rgba(96, 165, 250, 0.6)'
return {
paths: calculatedPaths,

View File

@ -12,12 +12,7 @@
'use client'
import React, {
useCallback,
useRef,
type RefObject,
type TouchEvent as ReactTouchEvent,
} from 'react'
import React, { useCallback, useRef, type RefObject, type TouchEvent as ReactTouchEvent } from 'react'
import type { UseMagnifierStateReturn } from './useMagnifierState'
@ -228,10 +223,8 @@ export function useMagnifierTouch(options: UseMagnifierTouchOptions): UseMagnifi
totalDeltaRef.current.y += deltaY
// Track if user has moved significantly
if (
Math.abs(totalDeltaRef.current.x) > moveThreshold ||
Math.abs(totalDeltaRef.current.y) > moveThreshold
) {
if (Math.abs(totalDeltaRef.current.x) > moveThreshold ||
Math.abs(totalDeltaRef.current.y) > moveThreshold) {
magnifierState.didMoveRef.current = true
}

View File

@ -1,224 +0,0 @@
/**
* OtherPlayerCursors Component
*
* Renders cursors for other players in multiplayer mode.
* Shows crosshairs and player emojis at cursor positions.
* Uses MapRendererContext for shared refs and viewport info.
*/
'use client'
import { memo } from 'react'
import type { Player } from '@/types/player'
import { useMapRendererContext } from '../map-renderer'
import { getRenderedViewport } from '../labels'
// ============================================================================
// Types
// ============================================================================
export interface CursorPosition {
x: number
y: number
playerId: string
}
export interface OtherPlayerCursorsProps {
/** Map of user ID to cursor position */
otherPlayerCursors: Record<string, CursorPosition | null>
/** Current viewer's user ID (to filter out own cursor) */
viewerId: string | null
/** Game mode */
gameMode: 'cooperative' | 'race' | 'turn-based' | undefined
/** Current player ID (for turn-based mode) */
currentPlayer: string | null | undefined
/** Local player ID */
localPlayerId: string | null | undefined
/** Player metadata by player ID */
playerMetadata: Record<string, Player>
/** Member players by user ID (for looking up remote players) */
memberPlayers: Record<string, Player[]>
}
// ============================================================================
// Component
// ============================================================================
/**
* Renders cursors for other players in multiplayer sessions.
* Shows in cooperative mode (always) and turn-based mode (when not our turn).
*/
export const OtherPlayerCursors = memo(function OtherPlayerCursors({
otherPlayerCursors,
viewerId,
gameMode,
currentPlayer,
localPlayerId,
playerMetadata,
memberPlayers,
}: OtherPlayerCursorsProps) {
// Get shared state from context
const { svgRef, containerRef, parsedViewBox } = useMapRendererContext()
if (!svgRef.current || !containerRef.current) return null
return (
<>
{Object.entries(otherPlayerCursors).map(([cursorUserId, position]) => {
// Skip our own cursor (by viewerId) and null positions
if (cursorUserId === viewerId || !position) return null
// In turn-based mode, only show other cursors when it's not our turn
if (gameMode === 'turn-based' && currentPlayer === localPlayerId) return null
// Get player metadata for emoji and color (playerId is in position data)
// First check playerMetadata, then fall back to memberPlayers (for remote players)
let player = playerMetadata[position.playerId]
if (!player) {
// Player not in local playerMetadata - look through memberPlayers
// memberPlayers is keyed by userId and contains arrays of players
for (const players of Object.values(memberPlayers)) {
const found = players.find((p) => p.id === position.playerId)
if (found) {
player = found
break
}
}
}
if (!player) {
console.log(
'[CursorShare] ⚠️ No player found in playerMetadata or memberPlayers for playerId:',
position.playerId
)
return null
}
// In collaborative mode, find all players from the same session and show all their emojis
// Use memberPlayers (from roomData) which is the canonical source of player ownership
const sessionPlayers =
gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId]
? memberPlayers[cursorUserId]
: [player]
// Convert SVG coordinates to screen coordinates (accounting for preserveAspectRatio letterboxing)
const svgRect = svgRef.current!.getBoundingClientRect()
const containerRect = containerRef.current!.getBoundingClientRect()
const viewport = getRenderedViewport(
svgRect,
parsedViewBox.x,
parsedViewBox.y,
parsedViewBox.width,
parsedViewBox.height
)
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
const screenX = (position.x - parsedViewBox.x) * viewport.scale + svgOffsetX
const screenY = (position.y - parsedViewBox.y) * viewport.scale + svgOffsetY
// Check if cursor is within rendered viewport bounds
if (
screenX < svgOffsetX ||
screenX > svgOffsetX + viewport.renderedWidth ||
screenY < svgOffsetY ||
screenY > svgOffsetY + viewport.renderedHeight
) {
return null
}
return (
<div
key={`cursor-${cursorUserId}`}
data-element="other-player-cursor"
data-player-id={position.playerId}
data-user-id={cursorUserId}
style={{
position: 'absolute',
left: screenX,
top: screenY,
pointerEvents: 'none',
zIndex: 100,
}}
>
{/* Crosshair - centered on the cursor position */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
style={{
position: 'absolute',
left: -12, // Half of width to center
top: -12, // Half of height to center
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))',
}}
>
{/* Outer ring */}
<circle
cx="12"
cy="12"
r="8"
fill="none"
stroke={player.color}
strokeWidth="2"
opacity="0.8"
/>
{/* Cross lines */}
<line
x1="12"
y1="2"
x2="12"
y2="8"
stroke={player.color}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="12"
y1="16"
x2="12"
y2="22"
stroke={player.color}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="2"
y1="12"
x2="8"
y2="12"
stroke={player.color}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="16"
y1="12"
x2="22"
y2="12"
stroke={player.color}
strokeWidth="2"
strokeLinecap="round"
/>
{/* Center dot */}
<circle cx="12" cy="12" r="2" fill={player.color} />
</svg>
{/* Player emoji label(s) - positioned below crosshair */}
{/* In collaborative mode, show all emojis from the same session */}
<div
style={{
position: 'absolute',
left: '50%',
top: 14, // Below the crosshair (12px half-height + 2px gap)
transform: 'translateX(-50%)',
fontSize: '16px',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
whiteSpace: 'nowrap',
}}
>
{sessionPlayers.map((p) => p.emoji).join('')}
</div>
</div>
)
})}
</>
)
})

View File

@ -1,8 +0,0 @@
/**
* Multiplayer Feature Module
*
* Components and utilities for multiplayer functionality in Know Your World.
*/
export type { CursorPosition, OtherPlayerCursorsProps } from './OtherPlayerCursors'
export { OtherPlayerCursors } from './OtherPlayerCursors'

View File

@ -103,7 +103,9 @@ export function MapRendererProvider({ children, value }: MapRendererProviderProp
]
)
return <MapRendererContext.Provider value={memoizedValue}>{children}</MapRendererContext.Provider>
return (
<MapRendererContext.Provider value={memoizedValue}>{children}</MapRendererContext.Provider>
)
}
// ============================================================================

View File

@ -177,7 +177,14 @@ export interface FlashProgress {
/**
* Hot/cold feedback type for visual indicators
*/
export type HotColdFeedbackType = 'freezing' | 'cold' | 'cool' | 'warm' | 'hot' | 'burning' | null
export type HotColdFeedbackType =
| 'freezing'
| 'cold'
| 'cool'
| 'warm'
| 'hot'
| 'burning'
| null
/**
* Heat-based styling for borders and glows

View File

@ -96,7 +96,13 @@ export function screenToSVG(
svgRect: DOMRect,
viewBox: ViewBoxComponents
): SVGPosition {
const viewport = getRenderedViewport(svgRect, viewBox.x, viewBox.y, viewBox.width, viewBox.height)
const viewport = getRenderedViewport(
svgRect,
viewBox.x,
viewBox.y,
viewBox.width,
viewBox.height
)
// Calculate offset from container origin to SVG rendered content
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
@ -124,7 +130,13 @@ export function svgToScreen(
svgRect: DOMRect,
viewBox: ViewBoxComponents
): CursorPosition {
const viewport = getRenderedViewport(svgRect, viewBox.x, viewBox.y, viewBox.width, viewBox.height)
const viewport = getRenderedViewport(
svgRect,
viewBox.x,
viewBox.y,
viewBox.width,
viewBox.height
)
// Calculate offset from container origin to SVG rendered content
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX

View File

@ -1,13 +0,0 @@
/**
* User Preferences Feature Module
*
* User preference settings with localStorage persistence for Know Your World.
*/
export type {
UserPreferences,
UserPreferencesHandlers,
UseUserPreferencesOptions,
UseUserPreferencesReturn,
} from './useUserPreferences'
export { useUserPreferences } from './useUserPreferences'

View File

@ -1,139 +0,0 @@
/**
* useUserPreferences Hook
*
* Manages user preference settings persisted in localStorage for Know Your World game.
* Handles auto-speak, accent, auto-hint, and hot/cold audio feedback settings.
*/
'use client'
import { useState, useCallback, useEffect } from 'react'
// ============================================================================
// Types
// ============================================================================
export interface UserPreferences {
/** Whether hints are automatically spoken when revealed */
autoSpeak: boolean
/** Whether to use the target region's accent when speaking hints */
withAccent: boolean
/** Whether hints are automatically opened on region advance */
autoHint: boolean
/** Whether hot/cold audio feedback is enabled */
hotColdEnabled: boolean
}
export interface UserPreferencesHandlers {
/** Toggle auto-speak setting */
handleAutoSpeakChange: (enabled: boolean) => void
/** Toggle with-accent setting */
handleWithAccentChange: (enabled: boolean) => void
/** Toggle auto-hint setting */
handleAutoHintChange: (enabled: boolean) => void
/** Toggle hot/cold audio setting */
handleHotColdChange: (enabled: boolean) => void
}
export interface UseUserPreferencesOptions {
/** Current assistance level */
assistanceLevel?: string
/** Whether hot/cold is allowed by assistance settings */
assistanceAllowsHotCold?: boolean
}
export interface UseUserPreferencesReturn extends UserPreferences, UserPreferencesHandlers {}
// ============================================================================
// LocalStorage Keys
// ============================================================================
const STORAGE_KEYS = {
autoSpeak: 'knowYourWorld.autoSpeakHint',
withAccent: 'knowYourWorld.withAccent',
autoHint: 'knowYourWorld.autoHint',
hotColdAudio: 'knowYourWorld.hotColdAudio',
} as const
// ============================================================================
// Hook
// ============================================================================
/**
* Custom hook for managing user preferences with localStorage persistence.
* Auto-enables hot/cold for learning assistance level.
*/
export function useUserPreferences({
assistanceLevel,
assistanceAllowsHotCold = false,
}: UseUserPreferencesOptions = {}): UseUserPreferencesReturn {
// Auto-speak setting persisted in localStorage
const [autoSpeak, setAutoSpeak] = useState(() => {
if (typeof window === 'undefined') return false
return localStorage.getItem(STORAGE_KEYS.autoSpeak) === 'true'
})
// With accent setting persisted in localStorage (default false - use user's locale for consistent pronunciation)
const [withAccent, setWithAccent] = useState(() => {
if (typeof window === 'undefined') return false
const stored = localStorage.getItem(STORAGE_KEYS.withAccent)
return stored === null ? false : stored === 'true'
})
// Auto-hint setting persisted in localStorage (auto-opens hint on region advance)
const [autoHint, setAutoHint] = useState(() => {
if (typeof window === 'undefined') return false
return localStorage.getItem(STORAGE_KEYS.autoHint) === 'true'
})
// Hot/cold audio feedback setting persisted in localStorage
const [hotColdEnabled, setHotColdEnabled] = useState(() => {
if (typeof window === 'undefined') return false
return localStorage.getItem(STORAGE_KEYS.hotColdAudio) === 'true'
})
// Auto-enable hot/cold for learning mode (highest assistance level)
// This ensures all players in a learning game get hot/cold feedback enabled
useEffect(() => {
if (assistanceLevel === 'learning' && assistanceAllowsHotCold && !hotColdEnabled) {
setHotColdEnabled(true)
// Also persist to localStorage so it stays enabled if they navigate away
localStorage.setItem(STORAGE_KEYS.hotColdAudio, 'true')
}
}, [assistanceLevel, assistanceAllowsHotCold, hotColdEnabled])
// Persist auto-speak setting
const handleAutoSpeakChange = useCallback((enabled: boolean) => {
setAutoSpeak(enabled)
localStorage.setItem(STORAGE_KEYS.autoSpeak, String(enabled))
}, [])
// Persist with-accent setting
const handleWithAccentChange = useCallback((enabled: boolean) => {
setWithAccent(enabled)
localStorage.setItem(STORAGE_KEYS.withAccent, String(enabled))
}, [])
// Persist auto-hint setting
const handleAutoHintChange = useCallback((enabled: boolean) => {
setAutoHint(enabled)
localStorage.setItem(STORAGE_KEYS.autoHint, String(enabled))
}, [])
// Persist hot/cold audio setting
const handleHotColdChange = useCallback((enabled: boolean) => {
setHotColdEnabled(enabled)
localStorage.setItem(STORAGE_KEYS.hotColdAudio, String(enabled))
}, [])
return {
autoSpeak,
withAccent,
autoHint,
hotColdEnabled,
handleAutoSpeakChange,
handleWithAccentChange,
handleAutoHintChange,
handleHotColdChange,
}
}