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>
This commit is contained in:
parent
e5c982d6f5
commit
cf078b7765
6 changed files with 437 additions and 183 deletions
|
|
@ -23,11 +23,26 @@ interface Item {
|
|||
}
|
||||
|
||||
// Icon helper: convert raw icon ID to hex filename
|
||||
function iconHex(raw: number): string {
|
||||
if (!raw || raw <= 0) return '06000133';
|
||||
return (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||||
}
|
||||
function iconUrl(item: Item): string {
|
||||
const raw = item.icon ?? item.Icon ?? 0;
|
||||
if (raw === 0) return '/icons/06000133.png'; // fallback
|
||||
const hex = (raw + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||||
return `/icons/${hex}.png`;
|
||||
return `/icons/${iconHex(item.icon ?? item.Icon ?? 0)}.png`;
|
||||
}
|
||||
function overlayUrl(item: Item): string | null {
|
||||
const id = (item as any).icon_overlay_id;
|
||||
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
|
||||
const iv = item.IntValues;
|
||||
if (iv?.['218103849'] && Number(iv['218103849']) > 100) return `/icons/${iconHex(Number(iv['218103849']))}.png`;
|
||||
return null;
|
||||
}
|
||||
function underlayUrl(item: Item): string | null {
|
||||
const id = (item as any).icon_underlay_id;
|
||||
if (id && id > 0) return `/icons/${iconHex(id)}.png`;
|
||||
const iv = item.IntValues;
|
||||
if (iv?.['218103850'] && Number(iv['218103850']) > 100) return `/icons/${iconHex(Number(iv['218103850']))}.png`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function itemName(item: Item): string { return item.name ?? item.Name ?? 'Unknown'; }
|
||||
|
|
@ -77,12 +92,16 @@ const SLOT_BG: Record<number, string> = {};
|
|||
[2,4,134217728,268435456,536870912,1073741824].forEach(m => SLOT_BG[m] = '#1e3e3e');
|
||||
[2097152,1048576,4194304,16777216,33554432,8388608].forEach(m => SLOT_BG[m] = '#142040');
|
||||
|
||||
function ItemIcon({ item, size = 38 }: { item: Item; size?: number }) {
|
||||
function ItemIcon({ item, size = 36 }: { item: Item; size?: number }) {
|
||||
const under = underlayUrl(item);
|
||||
const over = overlayUrl(item);
|
||||
const imgStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: size, height: size, border: 'none', background: 'transparent', imageRendering: 'pixelated' };
|
||||
return (
|
||||
<div title={itemTooltip(item)} style={{ width: size, height: size, position: 'relative', cursor: 'help' }}>
|
||||
<img src={iconUrl(item)} alt={itemName(item)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', imageRendering: 'pixelated' }}
|
||||
{under && <img src={under} alt="" style={{ ...imgStyle, zIndex: 1 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
|
||||
<img src={iconUrl(item)} alt={itemName(item)} style={{ ...imgStyle, zIndex: 2 }}
|
||||
onError={e => { (e.target as HTMLImageElement).src = '/icons/06000133.png'; }} />
|
||||
{over && <img src={over} alt="" style={{ ...imgStyle, zIndex: 3 }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -154,12 +173,13 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|||
<div key={slot.key} title={item ? itemTooltip(item) : slot.name}
|
||||
style={{
|
||||
position: 'absolute', left: (slot.col - 1) * 44 + 4, top: (slot.row - 1) * 44 + 4,
|
||||
width: 40, height: 40, background: item ? slot.bg : `${slot.bg}55`,
|
||||
border: `1px solid ${item ? '#555' : '#2a2a2a'}`, borderRadius: 3,
|
||||
width: 36, height: 36, background: item ? '#5a5a62' : '#3a3a42',
|
||||
border: item ? '2px solid #00ffff' : '2px outset #6a6a72',
|
||||
boxShadow: item ? '0 0 5px #00ffff, inset 0 0 5px rgba(0,255,255,0.2)' : 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
}}>
|
||||
{item ? <ItemIcon item={item} size={36} /> :
|
||||
<span style={{ fontSize: '0.45rem', color: '#444', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>}
|
||||
{item ? <ItemIcon item={item} size={32} /> :
|
||||
<span style={{ fontSize: '0.42rem', color: '#555', textAlign: 'center', lineHeight: 1 }}>{slot.name}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -171,9 +191,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4, alignContent: 'flex-start' }}>
|
||||
{activeItems.map((item, i) => (
|
||||
<div key={item.item_id ?? item.Id ?? i} title={itemTooltip(item)}
|
||||
style={{ width: 40, height: 40, background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 2,
|
||||
style={{ width: 36, height: 36, background: 'linear-gradient(135deg, #3d007a 0%, #1a0033 100%)',
|
||||
border: '1px solid #4a148c',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'help', overflow: 'hidden' }}>
|
||||
<ItemIcon item={item} size={36} />
|
||||
<ItemIcon item={item} size={32} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,305 @@
|
|||
import React, { useEffect } from 'react';
|
||||
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;
|
||||
type: string;
|
||||
distance: number;
|
||||
bearing?: number;
|
||||
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;
|
||||
id: string; charName: string; zIndex: number;
|
||||
socket: WebSocket | null;
|
||||
nearbyObjects: NearbyObject[];
|
||||
radarData: any; // full nearby_objects message
|
||||
}
|
||||
|
||||
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, nearbyObjects }) => {
|
||||
// Send start_radar when window opens, stop_radar on close
|
||||
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(() => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
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 && socket.readyState === WebSocket.OPEN) {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ player_name: charName, command: 'stop_radar' }));
|
||||
}
|
||||
};
|
||||
}, [charName, socket]);
|
||||
|
||||
const objects = nearbyObjects || [];
|
||||
const sorted = [...objects].sort((a, b) => (a.distance ?? 999) - (b.distance ?? 999));
|
||||
// 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={480} height={420}>
|
||||
<div style={{ padding: '4px 8px', fontSize: '0.7rem', color: '#888', borderBottom: '1px solid #333' }}>
|
||||
Range: ~120m · {objects.length} objects nearby
|
||||
<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>
|
||||
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.73rem' }}>
|
||||
{objects.length === 0 ? (
|
||||
<div style={{ padding: 20, color: '#555', textAlign: 'center' }}>
|
||||
Waiting for radar data from plugin...
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#777', fontSize: '0.65rem', textTransform: 'uppercase' }}>
|
||||
<th style={{ textAlign: 'left', padding: '4px 6px' }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '4px 4px' }}>Type</th>
|
||||
<th style={{ textAlign: 'right', padding: '4px 6px' }}>Distance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((obj, i) => (
|
||||
<tr key={obj.id ?? i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '3px 6px', fontWeight: 500 }}>{obj.name}</td>
|
||||
<td style={{ padding: '3px 4px', color: '#888', fontSize: '0.68rem' }}>{obj.type || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '3px 6px', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{obj.distance != null ? `${Math.round(obj.distance)}m` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,11 +36,9 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, near
|
|||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
case 'inv':
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
case 'radar': {
|
||||
const radarData = nearbyObjects.get(charName);
|
||||
case 'radar':
|
||||
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
socket={socket} nearbyObjects={radarData?.objects ?? []} />;
|
||||
}
|
||||
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;
|
||||
case 'combat':
|
||||
return <CombatStatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
case 'issues':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue