first
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { Html } from '@react-three/drei';
|
||||
import { useFrame, ThreeEvent } from '@react-three/fiber';
|
||||
import * as THREE from 'three';
|
||||
import { SocketName, getSocketTransform } from './SocketPoints';
|
||||
|
||||
interface InteractiveHotspotProps {
|
||||
socketName: SocketName;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick?: (socketName: SocketName) => void;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export function InteractiveHotspot({
|
||||
socketName,
|
||||
label,
|
||||
icon = '+',
|
||||
onClick,
|
||||
visible = true,
|
||||
}: InteractiveHotspotProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const ringRef = useRef<THREE.Mesh>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const transform = getSocketTransform(socketName);
|
||||
|
||||
// Pulsing animation for the hotspot
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
const scale = 1 + Math.sin(state.clock.elapsedTime * 3) * 0.1;
|
||||
meshRef.current.scale.setScalar(hovered ? 1.3 : scale);
|
||||
}
|
||||
if (ringRef.current) {
|
||||
const ringScale = 1 + Math.sin(state.clock.elapsedTime * 2) * 0.15;
|
||||
ringRef.current.scale.setScalar(ringScale);
|
||||
const opacity = 0.3 + Math.sin(state.clock.elapsedTime * 2) * 0.2;
|
||||
if (ringRef.current.material instanceof THREE.MeshBasicMaterial) {
|
||||
ringRef.current.material.opacity = opacity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(socketName);
|
||||
},
|
||||
[onClick, socketName]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
setHovered(true);
|
||||
setShowTooltip(true);
|
||||
document.body.style.cursor = 'pointer';
|
||||
}, []);
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
setHovered(false);
|
||||
setShowTooltip(false);
|
||||
document.body.style.cursor = 'auto';
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<group position={transform.position}>
|
||||
{/* 3D pulsing sphere */}
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
onClick={handleClick}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<sphereGeometry args={[0.025, 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={hovered ? '#3b82f6' : '#f59e0b'}
|
||||
emissive={hovered ? '#3b82f6' : '#f59e0b'}
|
||||
emissiveIntensity={hovered ? 0.8 : 0.4}
|
||||
transparent
|
||||
opacity={0.9}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Outer ring */}
|
||||
<mesh ref={ringRef} rotation={[0, 0, 0]}>
|
||||
<ringGeometry args={[0.03, 0.04, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#f59e0b"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* HTML label */}
|
||||
<Html
|
||||
position={[0, 0.06, 0]}
|
||||
center
|
||||
distanceFactor={8}
|
||||
style={{
|
||||
transition: 'all 0.2s',
|
||||
opacity: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{visible && showTooltip && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSize: '12px',
|
||||
color: '#1a1a2e',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
animation: 'fadeIn 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{label}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#94a3b8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
background: '#3b82f6',
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
Click to customize
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Hotspot panel component to manage visibility of multiple hotspots
|
||||
interface HotspotPanelProps {
|
||||
onSocketClick?: (socketName: SocketName) => void;
|
||||
visibleSockets?: SocketName[];
|
||||
}
|
||||
|
||||
export function HotspotPanel({ onSocketClick, visibleSockets }: HotspotPanelProps) {
|
||||
const defaultSockets: SocketName[] = ['head', 'chest', 'left_shoulder', 'right_shoulder', 'back', 'base'];
|
||||
const socketsToShow = visibleSockets || defaultSockets;
|
||||
|
||||
const socketLabels: Record<SocketName, string> = {
|
||||
head: 'Head Mount',
|
||||
chest: 'Chest Plate',
|
||||
left_shoulder: 'Left Shoulder',
|
||||
right_shoulder: 'Right Shoulder',
|
||||
back: 'Back Pack',
|
||||
base: 'Base Unit',
|
||||
};
|
||||
|
||||
const socketIcons: Record<SocketName, string> = {
|
||||
head: 'H',
|
||||
chest: 'C',
|
||||
left_shoulder: 'L',
|
||||
right_shoulder: 'R',
|
||||
back: 'B',
|
||||
base: 'U',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{socketsToShow.map((socket) => (
|
||||
<InteractiveHotspot
|
||||
key={socket}
|
||||
socketName={socket}
|
||||
label={socketLabels[socket]}
|
||||
icon={socketIcons[socket]}
|
||||
onClick={onSocketClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user