feat(v2): Phases 2-6 — trails, heatmap, portals, windows, effects
Phase 2 — Map overlays: - TrailsSVG: SVG polylines per character from /trails, polled 2s - HeatmapCanvas: canvas radial gradients from /spawns/heatmap - PortalMarkers: emoji markers from /portals - Sidebar toggles for heatmap and portals Phase 3 — Draggable windows: - WindowManagerContext: z-index stack for open windows - DraggableWindow: generic shell with drag-header, close btn, z-stack - ChatWindow: color-coded messages + input form (1000 msg buffer) - CharacterWindow: combat stats with monster damage table - InventoryWindow: item table with material/set/AL/dmg/workmanship - WindowRenderer: reads context, renders all open windows - Action buttons (Chat/Stats/Inv/Char/Radar) now open windows Phase 4 — Window types share same DraggableWindow shell with character-specific content. Combat stats and inventory via API. Phase 5 — Effects: - RareNotification: slide-in/slide-out banner with gold border - Fireworks: 30-particle explosion with CSS custom property animation - Notification queue with 6s display + 0.5s exit animation Phase 6 — Polish: - Window header uses modern blue gradient (not solid purple) - Chat uses monospace font - All overlay layers properly stacked (heatmap → trails → dots → portals) - Mobile: sidebar stacks above map at 768px breakpoint - Chat messages tracked per-character in useLiveData Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
183d662bb9
commit
de7b547349
20 changed files with 1040 additions and 193 deletions
61
frontend/src/components/windows/CharacterWindow.tsx
Normal file
61
frontend/src/components/windows/CharacterWindow.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
|
||||
.then(setStats).catch(() => {});
|
||||
}, [charName]);
|
||||
|
||||
const session = (stats as any)?.session;
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
|
||||
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||
{session ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Session</strong>: {session.total_kills ?? 0} kills, {(session.total_damage_given ?? 0).toLocaleString()} dmg given
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Monsters fought</strong>: {Object.keys(session.monsters ?? {}).filter((k: string) => k !== '__cloak_surges__').length}
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888' }}>
|
||||
<th style={{ textAlign: 'left', padding: '2px 4px' }}>Monster</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Kills</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Dmg Given</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.values(session.monsters ?? {})
|
||||
.filter((m: any) => m.name !== '__cloak_surges__')
|
||||
.sort((a: any, b: any) => (b.damage_given ?? 0) - (a.damage_given ?? 0))
|
||||
.slice(0, 30)
|
||||
.map((m: any) => (
|
||||
<tr key={m.name} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={{ padding: '2px 4px' }}>{m.name}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{m.kill_count}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{(m.damage_given ?? 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#666' }}>Loading combat data...</div>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
45
frontend/src/components/windows/ChatWindow.tsx
Normal file
45
frontend/src/components/windows/ChatWindow.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
|
||||
interface ChatMsg {
|
||||
text: string;
|
||||
color?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const CHAT_COLORS: Record<number, string> = {
|
||||
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
|
||||
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
|
||||
21:'#FF8888', 22:'#FFAA66',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
messages: ChatMsg[];
|
||||
}
|
||||
|
||||
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages }) => {
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
||||
<div className="ml-chat-messages" ref={msgsRef}>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form className="ml-chat-form" onSubmit={e => { e.preventDefault(); setInput(''); }}>
|
||||
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
|
||||
</form>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
58
frontend/src/components/windows/DraggableWindow.tsx
Normal file
58
frontend/src/components/windows/DraggableWindow.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useRef, useCallback, useEffect } from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
zIndex: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 700, height = 340, children }) => {
|
||||
const { closeWindow, bringToFront } = useWindowManager();
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 });
|
||||
const posRef = useRef({ x: 420, y: 10 + Math.random() * 40 });
|
||||
|
||||
const onHeaderDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
bringToFront(id);
|
||||
const rect = winRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
|
||||
}, [id, bringToFront]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d.dragging || !winRef.current) return;
|
||||
posRef.current.x = d.ox + (e.clientX - d.sx);
|
||||
posRef.current.y = d.oy + (e.clientY - d.sy);
|
||||
winRef.current.style.left = `${posRef.current.x}px`;
|
||||
winRef.current.style.top = `${posRef.current.y}px`;
|
||||
};
|
||||
const onUp = () => { dragRef.current.dragging = false; };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={winRef}
|
||||
className="ml-window"
|
||||
style={{ zIndex, width, height, left: posRef.current.x, top: posRef.current.y }}
|
||||
onMouseDown={() => bringToFront(id)}
|
||||
>
|
||||
<div className="ml-window-header" onMouseDown={onHeaderDown}>
|
||||
<span className="ml-window-title">{title}</span>
|
||||
<button className="ml-window-close" onClick={() => closeWindow(id)}>×</button>
|
||||
</div>
|
||||
<div className="ml-window-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/components/windows/InventoryWindow.tsx
Normal file
70
frontend/src/components/windows/InventoryWindow.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
|
||||
interface Item {
|
||||
Name: string;
|
||||
ObjectClass?: number;
|
||||
Icon?: number;
|
||||
Value?: number;
|
||||
Burden?: number;
|
||||
ArmorLevel?: number;
|
||||
MaxDamage?: number;
|
||||
Workmanship?: number;
|
||||
Material?: string;
|
||||
ItemSet?: string;
|
||||
Imbue?: string;
|
||||
EquipSkill?: string;
|
||||
}
|
||||
|
||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
|
||||
.then(d => setItems(d.items ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [charName]);
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
|
||||
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
37
frontend/src/components/windows/WindowRenderer.tsx
Normal file
37
frontend/src/components/windows/WindowRenderer.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import { ChatWindow } from './ChatWindow';
|
||||
import { CharacterWindow } from './CharacterWindow';
|
||||
import { InventoryWindow } from './InventoryWindow';
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
characters: Map<string, CharacterState>;
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
}
|
||||
|
||||
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) => {
|
||||
const { windows } = useWindowManager();
|
||||
|
||||
return (
|
||||
<>
|
||||
{windows.map(w => {
|
||||
const charName = w.charName ?? '';
|
||||
|
||||
if (w.id.startsWith('chat-')) {
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={chatMessages.get(charName) ?? []} />;
|
||||
}
|
||||
if (w.id.startsWith('char-') || w.id.startsWith('combat-')) {
|
||||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
if (w.id.startsWith('inv-')) {
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
// Fallback: generic window with placeholder
|
||||
return (
|
||||
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue