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:
Erik 2026-04-12 15:58:58 +02:00
parent 183d662bb9
commit de7b547349
20 changed files with 1040 additions and 193 deletions

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

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

View 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)}>&times;</button>
</div>
<div className="ml-window-content">
{children}
</div>
</div>
);
};

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

View 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={[]} />
);
})}
</>
);
};