import React, { useEffect, useRef, useState, useCallback } from 'react'; import { DraggableWindow } from './DraggableWindow'; const CANVAS_SIZE = 300; const DEFAULT_RANGE = 0.5; // AC units, ~120m const RADAR_COLORS: Record = { Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44', Portal: '#aa44ff', Corpse: '#ff8800', Container: '#cccc44', Door: '#888888', }; function compassDir(angleDeg: number): string { const a = ((angleDeg % 360) + 360) % 360; const dirs = ['N','NE','E','SE','S','SW','W','NW']; return dirs[Math.round(a / 45) % 8]; } interface NearbyObject { id: number; name: string; object_class?: string; type?: string; ew?: number; ns?: number; distance?: number; bearing?: number; raw_x?: number; raw_y?: number; _px?: number; _py?: number; } interface Props { id: string; charName: string; zIndex: number; socket: WebSocket | null; radarData: any; // full nearby_objects message } export const RadarWindow: React.FC = ({ id, charName, zIndex, socket, radarData }) => { const canvasRef = useRef(null); const rangeRef = useRef(DEFAULT_RANGE); const [range, setRange] = useState(DEFAULT_RANGE); const [selectedId, setSelectedId] = useState(null); const mapImgRef = useRef(null); const objectsRef = useRef([]); // Load map image once useEffect(() => { const img = new Image(); img.src = '/dereth.png'; img.onload = () => { mapImgRef.current = img; }; }, []); // Send start_radar on open, stop_radar on close useEffect(() => { if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ player_name: charName, command: 'start_radar' })); } return () => { if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ player_name: charName, command: 'stop_radar' })); } }; }, [charName, socket]); // Scroll to zoom const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const factor = e.deltaY > 0 ? 1.25 : 0.8; rangeRef.current = Math.max(0.02, Math.min(5.0, rangeRef.current * factor)); setRange(rangeRef.current); }, []); // Click to select const handleCanvasClick = useCallback((e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const mx = (e.clientX - rect.left) * (canvas.width / rect.width); const my = (e.clientY - rect.top) * (canvas.height / rect.height); let closestObj: NearbyObject | null = null; let closestDist = 20; objectsRef.current.forEach(obj => { if (obj._px === undefined) return; const d = Math.sqrt((mx - obj._px) ** 2 + (my - obj._py!) ** 2); if (d < closestDist) { closestDist = d; closestObj = obj; } }); setSelectedId(closestObj ? (closestObj as NearbyObject).id : null); }, []); // Render canvas useEffect(() => { const canvas = canvasRef.current; if (!canvas || !radarData) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const size = CANVAS_SIZE; const cx = size / 2, cy = size / 2; const objects: NearbyObject[] = radarData.objects ?? []; const playerEW = radarData.player_ew ?? 0; const playerNS = radarData.player_ns ?? 0; const heading = radarData.player_heading ?? 0; const isDungeon = radarData.is_dungeon ?? false; const playerX = radarData.player_x ?? 0; const playerY = radarData.player_y ?? 0; const currentRange = rangeRef.current; const scale = isDungeon ? (size / 2) / (currentRange * 240) : (size / 2) / currentRange; const headingRad = heading * Math.PI / 180; // Clear + dark circle background ctx.clearRect(0, 0, size, size); ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(cx, cy, cx, 0, Math.PI * 2); ctx.fill(); // Clip to circle ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2); ctx.clip(); // Semi-transparent map background (overworld) if (!isDungeon && mapImgRef.current) { const mapImg = mapImgRef.current; const pixPerCoord = mapImg.naturalWidth / 204.2; const mapCenterX = (playerEW + 102.1) * pixPerCoord; const mapCenterY = (102.1 - playerNS) * pixPerCoord; ctx.globalAlpha = 0.4; ctx.save(); ctx.translate(cx, cy); ctx.rotate(-headingRad); ctx.drawImage(mapImg, mapCenterX - (cx / scale) * pixPerCoord, mapCenterY - (cy / scale) * pixPerCoord, (size / scale) * pixPerCoord, (size / scale) * pixPerCoord, -cx, -cy, size, size); ctx.restore(); ctx.globalAlpha = 1.0; } ctx.restore(); // Range rings (4) ctx.strokeStyle = '#333'; ctx.lineWidth = 1; for (let i = 1; i <= 4; i++) { ctx.beginPath(); ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2); ctx.stroke(); } // Crosshairs ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, size); ctx.moveTo(0, cy); ctx.lineTo(size, cy); ctx.stroke(); // Compass labels ctx.font = 'bold 12px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; [{ l: 'N', a: 0 }, { l: 'E', a: Math.PI / 2 }, { l: 'S', a: Math.PI }, { l: 'W', a: -Math.PI / 2 }].forEach(({ l, a }) => { const ra = a - headingRad; ctx.fillStyle = l === 'N' ? '#cc4444' : '#888'; ctx.fillText(l, cx + Math.sin(ra) * (cx - 12), cy - Math.cos(ra) * (cx - 12)); }); // Facing line ctx.strokeStyle = '#666'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, cy - cx * 0.85); ctx.stroke(); // Entity dots const rotAngle = isDungeon ? (Math.PI - headingRad) : headingRad; const cosA = Math.cos(rotAngle), sinA = Math.sin(rotAngle); objects.forEach(obj => { let dX: number, dY: number; if (isDungeon && obj.raw_x !== undefined) { dX = -(obj.raw_x - playerX); dY = (obj.raw_y! - playerY); } else { dX = (obj.ew ?? 0) - playerEW; dY = (obj.ns ?? 0) - playerNS; } const dx = dX * cosA - dY * sinA; const dy = isDungeon ? (dX * sinA + dY * cosA) : -(dX * sinA + dY * cosA); const px = cx + dx * scale; const py = cy + dy * scale; const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); if (distFromCenter > cx - 4) return; obj._px = px; obj._py = py; const objClass = obj.object_class ?? obj.type ?? ''; const color = RADAR_COLORS[objClass] ?? '#888'; const isSel = obj.id === selectedId; const dotSize = isSel ? 6 : (objClass === 'Monster' || objClass === 'Player') ? 4 : 3; if (isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(px, py, dotSize + 3, 0, Math.PI * 2); ctx.stroke(); } ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px, py, dotSize, 0, Math.PI * 2); ctx.fill(); if (objClass === 'Player' || objClass === 'Portal' || isSel) { ctx.fillStyle = isSel ? '#fff' : color; ctx.font = '9px monospace'; ctx.textAlign = 'left'; ctx.fillText(obj.name, px + 6, py + 3); } }); objectsRef.current = objects; // Player dot (center) ctx.fillStyle = '#ffcc00'; ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); }, [radarData, range, selectedId]); // Entity list with distance + direction const entities = (radarData?.objects ?? []).map((obj: any) => { const pEW = radarData?.player_ew ?? 0; const pNS = radarData?.player_ns ?? 0; const isDungeon = radarData?.is_dungeon ?? false; const pX = radarData?.player_x ?? 0; const pY = radarData?.player_y ?? 0; let dX: number, dY: number, dist: number; if (isDungeon && obj.raw_x !== undefined) { dX = -(obj.raw_x - pX); dY = obj.raw_y - pY; dist = Math.sqrt(dX * dX + dY * dY); } else { dX = (obj.ew ?? 0) - pEW; dY = (obj.ns ?? 0) - pNS; dist = Math.sqrt(dX * dX + dY * dY) * 240; } const angle = Math.atan2(dX, dY) * 180 / Math.PI; return { ...obj, dist, dir: compassDir(angle) }; }).sort((a: any, b: any) => a.dist - b.dist); const rangeMeters = Math.round(range * 240); return ( {/* Controls */}
Range: ~{rangeMeters}m Scroll to zoom
{/* Canvas */} {/* Entity list */}
{/* Header */}
Name Type Dist Dir
{entities.length === 0 && (
Waiting for radar data...
)} {entities.map((obj: any) => { const objClass = obj.object_class ?? obj.type ?? ''; const color = RADAR_COLORS[objClass] ?? '#888'; const isSel = obj.id === selectedId; return (
setSelectedId(isSel ? null : obj.id)} style={{ display: 'flex', alignItems: 'center', padding: '2px 6px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer', color: '#ccc', background: isSel ? '#1a2a3a' : '', borderLeft: isSel ? '2px solid #4488ff' : '2px solid transparent', }}> {obj.name} {objClass} {obj.dist < 1000 ? `${Math.round(obj.dist)}m` : `${(obj.dist / 1000).toFixed(1)}km`} {obj.dir}
); })}
); };