Functional:
1. Chat: "▼ New messages below" indicator when scrolled up, click to jump
2. Combat stats: "Clear Session" button (red, with confirm dialog)
3. Inventory: live updates via inventory_delta WS (re-fetches on change)
4. Inventory: real mana time from equipment_cantrip_state WS (live
countdown with state dot: green=active, red=inactive, yellow=unknown)
Visual:
5. Thin separator line between tool links and sort buttons
6. Selected player row highlighted with darker background (#2a3344)
7. Scroll-to-top button (▲) appears when scrolled past 200px in player list
UX:
8. Double-click player dot on map opens their chat window
9. Right-click player dot shows context menu (Chat/Stats/Inv/Char/Combat/Radar)
10. Ctrl+D keyboard shortcut toggles between map and dashboard views
11. Sound notification on rare drops (880Hz sine beep via Web Audio API)
Backend:
12. Deep-merge lifetime offense/defense per element — accumulates
total_attacks, failed_attacks, crits, damage per AttackType×Element
instead of overwriting with latest session data
13. Startup cleanup: deletes stale combat_stats records from before
the lifetime fix (pre-2026-04-14T09:00Z)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import React, { useMemo, useState, useEffect } from 'react';
|
|
import { worldToPx } from '../../utils/coordinates';
|
|
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
|
import type { TelemetrySnapshot } from '../../types';
|
|
|
|
interface Props {
|
|
players: TelemetrySnapshot[];
|
|
imgW: number;
|
|
imgH: number;
|
|
getColor: (name: string) => string;
|
|
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
|
|
onSelect: (name: string) => void;
|
|
selectedPlayer: string | null;
|
|
}
|
|
|
|
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect, selectedPlayer }) => {
|
|
const { openWindow } = useWindowManager();
|
|
const [contextMenu, setContextMenu] = useState<{ name: string; x: number; y: number } | null>(null);
|
|
|
|
// Close context menu on any click
|
|
useEffect(() => {
|
|
const close = () => setContextMenu(null);
|
|
if (contextMenu) window.addEventListener('click', close);
|
|
return () => window.removeEventListener('click', close);
|
|
}, [contextMenu]);
|
|
const dots = useMemo(() =>
|
|
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
|
|
...p,
|
|
pos: worldToPx(p.ew, p.ns, imgW, imgH),
|
|
color: getColor(p.character_name),
|
|
})),
|
|
[players, imgW, imgH, getColor]);
|
|
|
|
return (
|
|
<div className="ml-dots-layer">
|
|
{dots.map(d => (
|
|
<div
|
|
key={d.character_name}
|
|
className={`ml-dot ${selectedPlayer === d.character_name ? 'ml-dot-selected' : ''}`}
|
|
style={{
|
|
left: d.pos.x,
|
|
top: d.pos.y,
|
|
backgroundColor: d.color,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
|
|
if (rect) onHover(d, e.clientX - rect.left, e.clientY - rect.top);
|
|
}}
|
|
onMouseLeave={() => onHover(null, 0, 0)}
|
|
onClick={() => onSelect(d.character_name)}
|
|
onDoubleClick={() => openWindow(`chat-${d.character_name}`, `Chat: ${d.character_name}`, d.character_name)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
const name = d.character_name;
|
|
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
|
|
const x = rect ? e.clientX - rect.left : e.clientX;
|
|
const y = rect ? e.clientY - rect.top : e.clientY;
|
|
setContextMenu({ name, x, y });
|
|
}}
|
|
/>
|
|
))}
|
|
{contextMenu && (
|
|
<div style={{ position: 'fixed', left: contextMenu.x + 410, top: contextMenu.y, background: '#1a1a1a', border: '1px solid #444', borderRadius: 4, zIndex: 9999, padding: '2px 0', fontSize: '0.75rem', boxShadow: '0 4px 12px rgba(0,0,0,0.5)', minWidth: 120 }}>
|
|
{[
|
|
{ label: 'Chat', id: 'chat' },
|
|
{ label: 'Stats', id: 'stats' },
|
|
{ label: 'Inventory', id: 'inv' },
|
|
{ label: 'Character', id: 'char' },
|
|
{ label: 'Combat', id: 'combat' },
|
|
{ label: 'Radar', id: 'radar' },
|
|
].map(item => (
|
|
<div key={item.id} onClick={() => { openWindow(`${item.id}-${contextMenu.name}`, `${item.label}: ${contextMenu.name}`, contextMenu.name); setContextMenu(null); }}
|
|
style={{ padding: '4px 12px', cursor: 'pointer', color: '#ccc' }}
|
|
onMouseEnter={e => (e.currentTarget.style.background = '#333')}
|
|
onMouseLeave={e => (e.currentTarget.style.background = '')}>
|
|
{item.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
PlayerDots.displayName = 'PlayerDots';
|