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:
parent
022ee0256a
commit
f0bf2050d3
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue