MosswartOverlord/frontend/src/components/windows/RadarWindow.tsx
Erik cf078b7765 fix(v2): full v1-style radar canvas + inventory icon composites
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>
2026-04-12 19:16:21 +02:00

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>
);
};