feat(v2): 13 improvements — functional, visual, UX, backend
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>
This commit is contained in:
parent
0b64c6ccff
commit
0112c59514
41 changed files with 404 additions and 112 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, lazy, Suspense } from 'react';
|
||||
import { useState, lazy, Suspense, useEffect } from 'react';
|
||||
import { MapLayout } from './components/map/MapLayout';
|
||||
import { useLiveData } from './hooks/useLiveData';
|
||||
import './styles/global.css';
|
||||
|
|
@ -15,6 +15,18 @@ export default function App() {
|
|||
);
|
||||
const data = useLiveData();
|
||||
|
||||
// Ctrl+D toggles map/dashboard
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
toggleView();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
const toggleView = () => {
|
||||
const next = viewMode === 'map' ? 'dashboard' : 'map';
|
||||
setViewMode(next);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,22 @@ export const RareNotification: React.FC<Props> = ({ recentRares }) => {
|
|||
for (const r of newRares) {
|
||||
const key = ++notifKey;
|
||||
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
|
||||
// Trigger fireworks
|
||||
// Trigger fireworks + sound
|
||||
triggerFireworks();
|
||||
try {
|
||||
// Simple beep using Web Audio API
|
||||
const ctx = new AudioContext();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.3;
|
||||
osc.start();
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
} catch { /* audio not available */ }
|
||||
// Auto-remove after 6s
|
||||
setTimeout(() => {
|
||||
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { apiFetch } from '../../api/client';
|
||||
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
|
||||
import { WindowManagerProvider, useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import { MapView } from './MapView';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { WindowRenderer } from '../windows/WindowRenderer';
|
||||
|
|
@ -37,6 +37,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
|||
setSelectedPlayer(prev => prev === name ? null : name);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<WindowManagerProvider>
|
||||
<div className="ml-layout">
|
||||
|
|
@ -54,6 +55,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
|||
onToggleHeatmap={setShowHeatmap}
|
||||
onTogglePortals={setShowPortals}
|
||||
version={version}
|
||||
selectedPlayer={selectedPlayer}
|
||||
/>
|
||||
<MapView
|
||||
players={players}
|
||||
|
|
@ -64,7 +66,8 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
|||
selectedPlayer={selectedPlayer}
|
||||
/>
|
||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
|
||||
nearbyObjects={data.nearbyObjects} socket={data.socketRef.current} />
|
||||
nearbyObjects={data.nearbyObjects} inventoryVersion={data.inventoryVersion}
|
||||
equipmentCantrips={data.equipmentCantrips} socket={data.socketRef.current} />
|
||||
<RareNotification recentRares={data.recentRares} />
|
||||
</div>
|
||||
</WindowManagerProvider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { worldToPx } from '../../utils/coordinates';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import type { TelemetrySnapshot } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -13,6 +14,15 @@ interface Props {
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
@ -38,8 +48,36 @@ export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, ge
|
|||
}}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,11 +18,12 @@ interface Props {
|
|||
onToggleHeatmap: (v: boolean) => void;
|
||||
onTogglePortals: (v: boolean) => void;
|
||||
version?: string;
|
||||
selectedPlayer?: string | null;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<Props> = ({
|
||||
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
|
||||
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version,
|
||||
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals, version, selectedPlayer,
|
||||
}) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [filter, setFilter] = useState('');
|
||||
|
|
@ -100,6 +101,7 @@ export const Sidebar: React.FC<Props> = ({
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid #333', marginTop: 4, paddingTop: 4 }} />
|
||||
<SortButtons value={sortKey} onChange={setSortKey} />
|
||||
<input
|
||||
className="ml-filter"
|
||||
|
|
@ -114,6 +116,7 @@ export const Sidebar: React.FC<Props> = ({
|
|||
vitals={deferredVitals}
|
||||
getColor={getColor}
|
||||
onSelect={onSelectPlayer}
|
||||
selectedPlayer={selectedPlayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { PlayerRow } from './PlayerRow';
|
||||
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
|
||||
|
||||
|
|
@ -7,18 +7,39 @@ interface Props {
|
|||
vitals: Map<string, VitalsMessage>;
|
||||
getColor: (name: string) => string;
|
||||
onSelect: (name: string) => void;
|
||||
selectedPlayer?: string | null;
|
||||
}
|
||||
|
||||
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => (
|
||||
<ul className="ml-player-list">
|
||||
{players.map(p => (
|
||||
<PlayerRow
|
||||
key={p.character_name}
|
||||
player={p}
|
||||
vitals={vitals.get(p.character_name) ?? null}
|
||||
color={getColor(p.character_name)}
|
||||
onSelect={() => onSelect(p.character_name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect, selectedPlayer }) => {
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (listRef.current) setShowTop(listRef.current.scrollTop > 200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||
<ul className="ml-player-list" ref={listRef} onScroll={handleScroll}>
|
||||
{players.map(p => (
|
||||
<PlayerRow
|
||||
key={p.character_name}
|
||||
player={p}
|
||||
vitals={vitals.get(p.character_name) ?? null}
|
||||
color={getColor(p.character_name)}
|
||||
onSelect={() => onSelect(p.character_name)}
|
||||
isSelected={selectedPlayer === p.character_name}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{showTop && (
|
||||
<button onClick={() => { listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
style={{ position: 'absolute', bottom: 8, right: 8, width: 28, height: 28, borderRadius: '50%',
|
||||
background: 'rgba(68,136,255,0.2)', border: '1px solid rgba(68,136,255,0.4)', color: '#6af',
|
||||
cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
▲
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ interface Props {
|
|||
vitals: VitalsMessage | null;
|
||||
color: string;
|
||||
onSelect: () => void;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
|
||||
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect, isSelected }) => {
|
||||
const { openWindow } = useWindowManager();
|
||||
const vtState = (p.vt_state || 'idle').toLowerCase();
|
||||
const isActive = vtState === 'combat' || vtState === 'hunt';
|
||||
|
|
@ -21,7 +22,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
|
|||
const name = p.character_name;
|
||||
|
||||
return (
|
||||
<li className="ml-player-row" style={{ borderLeftColor: color }}>
|
||||
<li className={`ml-player-row ${isSelected ? 'ml-player-selected' : ''}`} style={{ borderLeftColor: color }}>
|
||||
<div className="ml-pr-header" onClick={onSelect}>
|
||||
<span className="ml-pr-name">{name}</span>
|
||||
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>
|
||||
|
|
|
|||
|
|
@ -40,26 +40,34 @@ interface Props {
|
|||
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [hasNewBelow, setHasNewBelow] = useState(false);
|
||||
const historyRef = useRef<string[]>(loadHistory(charName));
|
||||
const historyIndexRef = useRef(-1); // -1 = not browsing history
|
||||
const savedInputRef = useRef(''); // preserves current input when browsing history
|
||||
const historyIndexRef = useRef(-1);
|
||||
const savedInputRef = useRef('');
|
||||
const userScrolledRef = useRef(false);
|
||||
|
||||
// Auto-scroll only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
const el = msgsRef.current;
|
||||
if (!el) return;
|
||||
if (!userScrolledRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
setHasNewBelow(false);
|
||||
} else {
|
||||
setHasNewBelow(true);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Track whether user has scrolled up from bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = msgsRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
||||
userScrolledRef.current = !atBottom;
|
||||
if (atBottom) setHasNewBelow(false);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = msgsRef.current;
|
||||
if (el) { el.scrollTop = el.scrollHeight; userScrolledRef.current = false; setHasNewBelow(false); }
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback((e: React.FormEvent) => {
|
||||
|
|
@ -118,6 +126,11 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, so
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasNewBelow && (
|
||||
<div onClick={scrollToBottom} style={{ padding: '3px 0', textAlign: 'center', fontSize: '0.65rem', color: '#6af', background: '#1a2a3a', cursor: 'pointer', borderTop: '1px solid #334' }}>
|
||||
▼ New messages below
|
||||
</div>
|
||||
)}
|
||||
<form className="ml-chat-form" onSubmit={handleSend}>
|
||||
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown} placeholder="Enter chat..." />
|
||||
|
|
|
|||
|
|
@ -100,10 +100,17 @@ export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) =>
|
|||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
|
||||
{/* Toggle */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}>
|
||||
{/* Toggle + Clear */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333', alignItems: 'center' }}>
|
||||
<button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button>
|
||||
<button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
{mode === 'session' && (
|
||||
<button style={{ fontSize: '0.6rem', padding: '2px 8px', background: 'rgba(204,68,68,0.15)', color: '#c66', border: '1px solid rgba(204,68,68,0.3)', borderRadius: 3, cursor: 'pointer' }}
|
||||
onClick={() => { if (confirm('Clear current session stats?')) { /* Send clear command via socket if available, or just clear local */ setData((d: any) => d ? { ...d, session: { total_damage_given: 0, total_damage_received: 0, total_kills: 0, total_aetheria_surges: 0, total_cloak_surges: 0, monsters: {} } } : d); } }}>
|
||||
Clear Session
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Monster list (left) */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
|||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
interface Props { id: string; charName: string; zIndex: number; inventoryVersion?: number; equipmentCantrips?: any; }
|
||||
|
||||
// ── Item normalization (handles both inventory-service snake_case and plugin PascalCase) ──
|
||||
function normalizeItem(raw: any): any {
|
||||
|
|
@ -157,7 +157,7 @@ function PackIcon({ iconSrc, isActive, fillPct, label, onClick }: {
|
|||
);
|
||||
}
|
||||
|
||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex, inventoryVersion, equipmentCantrips }) => {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePack, setActivePack] = useState<number | null>(null);
|
||||
|
|
@ -175,7 +175,7 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|||
setItems(rawItems.map(normalizeItem));
|
||||
setCharStats(stats);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [charName]);
|
||||
}, [charName, inventoryVersion]); // re-fetch when inventory_delta arrives
|
||||
|
||||
const handleHover = useCallback((item: any | null, e?: React.MouseEvent) => {
|
||||
if (item && e) setTooltip({ item, x: e.clientX, y: e.clientY });
|
||||
|
|
@ -352,21 +352,41 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 160 }}>
|
||||
<div style={{ padding: '4px 8px', fontSize: '0.72rem', fontWeight: 600, color: '#aaa', background: '#111', borderBottom: `1px solid ${gold}` }}>Mana</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '2px 0' }}>
|
||||
{Array.from(equippedMap.values())
|
||||
.filter((i: any) => (i.current_mana > 0 || i.max_mana > 0))
|
||||
.sort((a: any, b: any) => (a.current_mana ?? 999999) - (b.current_mana ?? 999999))
|
||||
.map((item: any, i: number) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
|
||||
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
|
||||
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: item.current_mana > 0 ? '#4c4' : '#c44', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana ?? 0}/{item.max_mana ?? 0}</div>
|
||||
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
|
||||
{item.max_mana > 0 ? formatManaTime(item.current_mana ?? 0, item.max_mana ?? 0) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
// Merge real cantrip state data if available
|
||||
const cantripItems = equipmentCantrips?.items ?? [];
|
||||
const cantripMap: Map<number, any> = new Map(cantripItems.map((c: any) => [c.item_id, c]));
|
||||
const snapshotTime = equipmentCantrips?.timestamp ? new Date(equipmentCantrips.timestamp).getTime() : 0;
|
||||
const elapsed = snapshotTime > 0 ? Math.max(0, (Date.now() - snapshotTime) / 1000) : 0;
|
||||
|
||||
return Array.from(equippedMap.values())
|
||||
.map((item: any) => {
|
||||
const cantrip = cantripMap.get(item.item_id);
|
||||
const curMana = cantrip?.current_mana ?? item.current_mana ?? 0;
|
||||
const maxMana = cantrip?.max_mana ?? item.max_mana ?? 0;
|
||||
const rawRemaining = cantrip?.mana_time_remaining_seconds ?? null;
|
||||
const liveRemaining = rawRemaining != null ? Math.max(0, rawRemaining - elapsed) : null;
|
||||
const state = cantrip?.state ?? (curMana > 0 ? 'active' : 'not_active');
|
||||
return { ...item, current_mana: curMana, max_mana: maxMana, liveRemaining, manaState: state };
|
||||
})
|
||||
.filter((i: any) => i.current_mana > 0 || i.max_mana > 0)
|
||||
.sort((a: any, b: any) => (a.liveRemaining ?? 999999) - (b.liveRemaining ?? 999999))
|
||||
.map((item: any, i: number) => {
|
||||
const stateColor = item.manaState === 'active' ? '#4c4' : item.manaState === 'not_active' ? '#c44' : '#da8';
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 4px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer' }}
|
||||
onMouseEnter={e => handleHover(item, e)} onMouseMove={e => handleHover(item, e)} onMouseLeave={() => handleHover(null)}>
|
||||
<div style={{ width: 20, height: 20, flexShrink: 0 }}><ItemIcon item={item} size={20} /></div>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: stateColor, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.68rem', color: '#ccc' }}>{item.name}</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#88bbff', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{item.current_mana}/{item.max_mana}</div>
|
||||
<div style={{ fontSize: '0.63rem', color: '#9c9', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', minWidth: 42, textAlign: 'right' }}>
|
||||
{item.liveRemaining != null ? formatSeconds(item.liveRemaining) : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{Array.from(equippedMap.values()).filter((i: any) => (i.current_mana > 0 || i.max_mana > 0)).length === 0 && (
|
||||
<div style={{ padding: 12, color: '#555', textAlign: 'center', fontSize: '0.7rem' }}>No mana items equipped</div>
|
||||
)}
|
||||
|
|
@ -378,11 +398,10 @@ export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
|||
);
|
||||
};
|
||||
|
||||
function formatManaTime(current: number, max: number): string {
|
||||
if (max <= 0 || current <= 0) return '0h00m';
|
||||
// Rough estimate: ~20 mana per second drain rate for typical items
|
||||
const seconds = current * 20;
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `~${hours}h${String(minutes).padStart(2, '0')}m`;
|
||||
function formatSeconds(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '0h00m';
|
||||
const s = Math.floor(totalSeconds);
|
||||
const hours = Math.floor(s / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
return `${hours}h${String(minutes).padStart(2, '0')}m`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ interface Props {
|
|||
characters: Map<string, CharacterState>;
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
nearbyObjects: Map<string, any>;
|
||||
inventoryVersion: number;
|
||||
equipmentCantrips: Map<string, any>;
|
||||
socket: WebSocket | null;
|
||||
}
|
||||
|
||||
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, socket }) => {
|
||||
export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socket }) => {
|
||||
const { windows } = useWindowManager();
|
||||
|
||||
return (
|
||||
|
|
@ -37,7 +39,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
|
|||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
vitals={characters.get(charName)?.vitals ?? undefined} />;
|
||||
case 'inv':
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
inventoryVersion={inventoryVersion} equipmentCantrips={equipmentCantrips.get(charName)} />;
|
||||
case 'radar':
|
||||
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
socket={socket} radarData={nearbyObjects.get(charName) ?? null} />;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export interface DashboardState {
|
|||
recentRares: RareMessage[];
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
nearbyObjects: Map<string, any>;
|
||||
inventoryVersion: number;
|
||||
equipmentCantrips: Map<string, any>;
|
||||
socketRef: React.RefObject<WebSocket | null>;
|
||||
}
|
||||
|
||||
|
|
@ -23,10 +25,11 @@ export function useLiveData(): DashboardState {
|
|||
const [totalRares, setTotalRares] = useState(0);
|
||||
const [totalKills, setTotalKills] = useState(0);
|
||||
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
|
||||
// Chat messages stored in ref to avoid re-renders on every message.
|
||||
// A counter state triggers re-render only when needed.
|
||||
const chatMessagesRef = useRef(new Map<string, Array<{ text: string; color?: number; timestamp: string }>>());
|
||||
const [chatVersion, setChatVersion] = useState(0);
|
||||
const [inventoryVersion, setInventoryVersion] = useState(0);
|
||||
const equipmentCantripRef = useRef(new Map<string, any>());
|
||||
const [equipCantripVersion, setEquipCantripVersion] = useState(0);
|
||||
const [nearbyObjects, setNearbyObjects] = useState<Map<string, any>>(new Map());
|
||||
const charsRef = useRef(characters);
|
||||
charsRef.current = characters;
|
||||
|
|
@ -57,6 +60,14 @@ export function useLiveData(): DashboardState {
|
|||
} else if (msg.type === 'rare') {
|
||||
const r = msg as RareMessage;
|
||||
setRecentRares(prev => [r, ...prev].slice(0, 50));
|
||||
} else if (msg.type === 'inventory_delta') {
|
||||
const d = msg as unknown as { character_name: string };
|
||||
// Bump inventory version so open inventory windows can re-fetch
|
||||
setInventoryVersion(v => v + 1);
|
||||
} else if (msg.type === 'equipment_cantrip_state') {
|
||||
const ecs = msg as unknown as { character_name: string; items: any[]; timestamp: string };
|
||||
equipmentCantripRef.current.set(ecs.character_name, ecs);
|
||||
setEquipCantripVersion(v => v + 1);
|
||||
} else if (msg.type === 'dungeon_map') {
|
||||
// Cache dungeon map data for radar rendering (stored on window for canvas access)
|
||||
const dm = msg as unknown as { landblock: string; z_levels: any[] };
|
||||
|
|
@ -169,5 +180,8 @@ export function useLiveData(): DashboardState {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const chatMessages = useMemo(() => chatMessagesRef.current, [chatVersion]);
|
||||
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, socketRef };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const equipmentCantrips = useMemo(() => equipmentCantripRef.current, [equipCantripVersion]);
|
||||
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, nearbyObjects, inventoryVersion, equipmentCantrips, socketRef };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@
|
|||
transition: background 0.1s;
|
||||
}
|
||||
.ml-player-row:hover { background: #252525; }
|
||||
.ml-player-row.ml-player-selected { background: #2a3344; }
|
||||
|
||||
.ml-pr-name {
|
||||
font-size: 0.82rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue