Radar — now pixel-accurate reproduction of v1: - 300×300 canvas with dark circular background - Semi-transparent dereth.png map overlay (heading-rotated) - 4 range rings + crosshair lines - Compass labels (N=red, E/S/W=gray) rotating with heading - Facing direction indicator line - Entity dots color-coded by type (Monster=red, Player=blue, NPC=green, Portal=purple, Corpse=orange, Container=yellow) - Player dot: gold center with white border - Heading-up rotation for all entity positions - Click to select entity (white selection ring) - Scroll to zoom (0.02-5.0 AC units range) - Entity list with color dot, name, type, distance, compass direction - Selected entity highlighted with blue left border Inventory — v1-style icon composites + slot styling: - 3-layer icon composite: underlay → base → overlay images using portal.dat offset formula + icon_overlay_id/IntValues - Equipment slots: 3D beveled border + cyan glow when equipped (matching v1's outset border + #00ffff shadow) - Pack item cells: purple gradient background (v1's #3d007a) - Proper 36×36px icon rendering with pixelated scaling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
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<string, string> = {
|
|
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<Props> = ({ id, charName, zIndex, socket, radarData }) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const rangeRef = useRef(DEFAULT_RANGE);
|
|
const [range, setRange] = useState(DEFAULT_RANGE);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const mapImgRef = useRef<HTMLImageElement | null>(null);
|
|
const objectsRef = useRef<NearbyObject[]>([]);
|
|
|
|
// 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<HTMLCanvasElement>) => {
|
|
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 (
|
|
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={360} height={560}>
|
|
{/* Controls */}
|
|
<div style={{ padding: '4px 8px', display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', borderBottom: '1px solid #333', background: '#1a1a1a' }}>
|
|
<span>Range: ~{rangeMeters}m</span>
|
|
<span style={{ fontSize: '0.65rem', color: '#555' }}>Scroll to zoom</span>
|
|
</div>
|
|
|
|
{/* Canvas */}
|
|
<canvas ref={canvasRef} width={CANVAS_SIZE} height={CANVAS_SIZE}
|
|
style={{ display: 'block', margin: '0 auto', borderBottom: '1px solid #333', cursor: 'crosshair', flexShrink: 0 }}
|
|
onWheel={handleWheel} onClick={handleCanvasClick} />
|
|
|
|
{/* Entity list */}
|
|
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.72rem', minHeight: 0 }}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#666', fontSize: '0.65rem', fontWeight: 600 }}>
|
|
<span style={{ width: 8 }}></span>
|
|
<span style={{ flex: 1, marginLeft: 6 }}>Name</span>
|
|
<span style={{ width: 55, textAlign: 'left' }}>Type</span>
|
|
<span style={{ width: 40, textAlign: 'right' }}>Dist</span>
|
|
<span style={{ width: 24, textAlign: 'center' }}>Dir</span>
|
|
</div>
|
|
{entities.length === 0 && (
|
|
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>
|
|
Waiting for radar data...
|
|
</div>
|
|
)}
|
|
{entities.map((obj: any) => {
|
|
const objClass = obj.object_class ?? obj.type ?? '';
|
|
const color = RADAR_COLORS[objClass] ?? '#888';
|
|
const isSel = obj.id === selectedId;
|
|
return (
|
|
<div key={obj.id} onClick={() => 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',
|
|
}}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0 }}></span>
|
|
<span style={{ flex: 1, marginLeft: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{obj.name}</span>
|
|
<span style={{ width: 55, color: '#888', fontSize: '0.65rem' }}>{objClass}</span>
|
|
<span style={{ width: 40, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
|
{obj.dist < 1000 ? `${Math.round(obj.dist)}m` : `${(obj.dist / 1000).toFixed(1)}km`}
|
|
</span>
|
|
<span style={{ width: 24, textAlign: 'center', color: '#666' }}>{obj.dir}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</DraggableWindow>
|
|
);
|
|
};
|