fix(v2): all reported issues — missing windows, broken features, layouts
Missing features (now added): 1. Vital Sharing window — polls /vital-sharing/peers, shows peer vitals with HP/STA/MANA bars, connection status, position, tags 2. Combat Stats window — full Mag-Tools style with monster list (left), damage breakdown grid (right), session/lifetime toggle, element matrix 3. Issues Board window — CRUD with categories, resolve/reopen, comments 4. Quest Status — links to /quest-status.html (separate page like v1) 5. Sidebar: added Issues + Vitals buttons, Quest link, Combat button per player row (6 buttons now: Chat/Stats/Inv/Char/Combat/Radar) Fixed functionality: 6. Radar — fixed command to "start_radar"/"stop_radar" (was wrong path) 7. Character window — redesigned with v1-style tabbed layout: Left tabs: Attributes (vitals bars + attribute grid) | Skills (specialized/trained grouped) | Titles Right tabs: Augs | Ratings | Other (allegiance) Header: level, race, gender, XP, luminance, deaths, skill credits 8. Stats window — proper Grafana iframe grid (4 panels 2x2) with time range selector (1H/6H/24H/7D) Color palette: expanded to 60 distinct colors (was 30) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b77450b6eb
commit
52e1bcd6b8
12 changed files with 710 additions and 226 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { PlayerList } from '../sidebar/PlayerList';
|
||||
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
|
||||
import { SidebarWindowButtons } from '../sidebar/SidebarWindowButtons';
|
||||
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -74,10 +75,12 @@ export const Sidebar: React.FC<Props> = ({
|
|||
|
||||
{/* Tool links */}
|
||||
<div className="ml-tool-links">
|
||||
<a href="/inventory.html" className="ml-tool-link">🔍 Inventory Search</a>
|
||||
<a href="/inventory.html" className="ml-tool-link">🔍 Inv Search</a>
|
||||
<a href="/suitbuilder.html" className="ml-tool-link">🛡️ Suitbuilder</a>
|
||||
<a href="/debug.html" className="ml-tool-link">🐛 Player Debug</a>
|
||||
<a href="/debug.html" className="ml-tool-link">🐛 Debug</a>
|
||||
<a href="/quest-status.html" target="_blank" className="ml-tool-link">📜 Quests</a>
|
||||
</div>
|
||||
<SidebarWindowButtons />
|
||||
|
||||
{/* Map toggles */}
|
||||
<div className="ml-toggles">
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
|
|||
<button className="ml-btn accent" onClick={() => openWindow(`stats-${name}`, `Stats: ${name}`, name)}>Stats</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Combat</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
15
frontend/src/components/sidebar/SidebarWindowButtons.tsx
Normal file
15
frontend/src/components/sidebar/SidebarWindowButtons.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
|
||||
export const SidebarWindowButtons: React.FC = () => {
|
||||
const { openWindow } = useWindowManager();
|
||||
|
||||
return (
|
||||
<div className="ml-tool-links">
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
|
||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,85 +6,167 @@ interface Props { id: string; charName: string; zIndex: number; }
|
|||
|
||||
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState<'attr' | 'skills' | 'titles'>('attr');
|
||||
const [rightTab, setRightTab] = useState<'augs' | 'ratings' | 'other'>('augs');
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`)
|
||||
.then(setData).catch(() => {});
|
||||
.then(d => setData(d)).catch(() => {});
|
||||
}, [charName]);
|
||||
|
||||
const sd = data?.stats_data;
|
||||
if (!data) {
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={600} height={500}>
|
||||
<div style={{ padding: 20, color: '#666' }}>Loading character data...</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
|
||||
const sd = data.stats_data || data;
|
||||
const attrs = sd.attributes || {};
|
||||
const skills = sd.skills || {};
|
||||
const vitals = sd.vitals || {};
|
||||
const titles = sd.titles || [];
|
||||
const properties = sd.properties || {};
|
||||
|
||||
const specSkills = Object.entries(skills).filter(([, v]: [string, any]) => v?.training === 'Specialized');
|
||||
const trainedSkills = Object.entries(skills).filter(([, v]: [string, any]) => v?.training === 'Trained');
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={550} height={500}>
|
||||
<div style={{ padding: 10, fontSize: '0.78rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||
{!sd ? (
|
||||
<div style={{ color: '#666' }}>Loading character data...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Level + XP header */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{data?.level && <span><strong>Level:</strong> {data.level}</span>}
|
||||
{data?.total_xp != null && <span><strong>Total XP:</strong> {Number(data.total_xp).toLocaleString()}</span>}
|
||||
{data?.unassigned_xp != null && <span><strong>Unassigned:</strong> {Number(data.unassigned_xp).toLocaleString()}</span>}
|
||||
{data?.luminance_earned != null && <span><strong>Luminance:</strong> {Number(data.luminance_earned).toLocaleString()}</span>}
|
||||
{data?.deaths != null && <span><strong>Deaths:</strong> {data.deaths}</span>}
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={620} height={520}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.75rem', color: '#ccc' }}>
|
||||
{/* Header: Level + Race + XP */}
|
||||
<div style={{ padding: '8px 10px', borderBottom: '1px solid #333', display: 'flex', flexWrap: 'wrap', gap: '12px', fontSize: '0.78rem' }}>
|
||||
{data.level && <span><strong>Lv {data.level}</strong></span>}
|
||||
{data.race && <span>{data.race}</span>}
|
||||
{data.gender && <span>{data.gender}</span>}
|
||||
{data.total_xp != null && <span>XP: {Number(data.total_xp).toLocaleString()}</span>}
|
||||
{data.unassigned_xp != null && <span>Unasgn: {Number(data.unassigned_xp).toLocaleString()}</span>}
|
||||
{data.luminance_earned != null && <span>Lum: {Number(data.luminance_earned).toLocaleString()}</span>}
|
||||
{data.deaths != null && <span>Deaths: {data.deaths}</span>}
|
||||
{sd.skill_credits != null && <span>Skill Credits: {sd.skill_credits}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', minHeight: 350 }}>
|
||||
{/* Left panel */}
|
||||
<div style={{ flex: 1, borderRight: '1px solid #333' }}>
|
||||
<div style={{ display: 'flex', gap: 2, padding: '4px 6px', borderBottom: '1px solid #333' }}>
|
||||
{(['attr', 'skills', 'titles'] as const).map(t => (
|
||||
<button key={t} className={`ml-stats-range-btn ${tab === t ? 'active' : ''}`}
|
||||
onClick={() => setTab(t)} style={{ flex: 1 }}>
|
||||
{t === 'attr' ? 'Attributes' : t === 'skills' ? 'Skills' : 'Titles'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
{sd.attributes && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Attributes</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
|
||||
{Object.entries(sd.attributes).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{sd.skills && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Skills</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 12px', fontSize: '0.72rem' }}>
|
||||
{Object.entries(sd.skills)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
|
||||
<div style={{ padding: 8 }}>
|
||||
{tab === 'attr' && (
|
||||
<>
|
||||
{/* Vital bars */}
|
||||
{Object.entries(vitals).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
|
||||
<span style={{ width: 55, color: '#888', fontSize: '0.7rem' }}>{k}</span>
|
||||
<div style={{ flex: 1, height: 6, background: '#222', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: '100%', height: '100%', background: k === 'health' ? '#c44' : k === 'stamina' ? '#ca0' : '#48f', borderRadius: 3 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vitals */}
|
||||
{sd.vitals && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Vitals</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
|
||||
{Object.entries(sd.vitals).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.current ?? JSON.stringify(v)) : v}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#aaa', width: 40, textAlign: 'right' }}>{v?.base ?? v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Attributes table */}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 4, fontSize: '0.72rem' }}>Attributes</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 16px' }}>
|
||||
{Object.entries(attrs).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span style={{ color: '#888', textTransform: 'capitalize' }}>{k}</span>
|
||||
<span>{v?.base ?? v} {v?.creation != null ? `(${v.creation})` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Raw data fallback if no structured sections */}
|
||||
{!sd.attributes && !sd.skills && !sd.vitals && (
|
||||
<pre style={{ fontSize: '0.68rem', color: '#888', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(sd, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === 'skills' && (
|
||||
<>
|
||||
{specSkills.length > 0 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 3, fontSize: '0.7rem' }}>Specialized</div>
|
||||
{specSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span style={{ color: '#ccc' }}>{k}</span>
|
||||
<span style={{ color: '#8f8' }}>{v?.base ?? v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{trainedSkills.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#888', marginBottom: 3, fontSize: '0.7rem' }}>Trained</div>
|
||||
{trainedSkills.sort(([a], [b]) => a.localeCompare(b)).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span style={{ color: '#999' }}>{k}</span>
|
||||
<span>{v?.base ?? v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{specSkills.length === 0 && trainedSkills.length === 0 && (
|
||||
<div style={{ color: '#555' }}>No skill data available</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'titles' && (
|
||||
titles.length > 0 ? (
|
||||
<ul style={{ paddingLeft: 16, margin: 0 }}>
|
||||
{titles.map((t: string, i: number) => <li key={i} style={{ padding: '1px 0' }}>{t}</li>)}
|
||||
</ul>
|
||||
) : <div style={{ color: '#555' }}>No titles</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div style={{ width: 220 }}>
|
||||
<div style={{ display: 'flex', gap: 2, padding: '4px 4px', borderBottom: '1px solid #333' }}>
|
||||
{(['augs', 'ratings', 'other'] as const).map(t => (
|
||||
<button key={t} className={`ml-stats-range-btn ${rightTab === t ? 'active' : ''}`}
|
||||
onClick={() => setRightTab(t)} style={{ flex: 1, fontSize: '0.6rem' }}>
|
||||
{t === 'augs' ? 'Augs' : t === 'ratings' ? 'Ratings' : 'Other'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: 6, fontSize: '0.7rem' }}>
|
||||
{rightTab === 'augs' && (
|
||||
Object.keys(properties).length > 0 ? (
|
||||
Object.entries(properties).slice(0, 20).map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{String(v)}</span>
|
||||
</div>
|
||||
))
|
||||
) : <div style={{ color: '#555' }}>No augmentation data</div>
|
||||
)}
|
||||
{rightTab === 'ratings' && (
|
||||
<div style={{ color: '#555' }}>Rating data will appear here from character_stats events</div>
|
||||
)}
|
||||
{rightTab === 'other' && (
|
||||
<>
|
||||
{data.allegiance && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#6aadff', marginBottom: 3 }}>Allegiance</div>
|
||||
{data.allegiance.name && <div>Name: {data.allegiance.name}</div>}
|
||||
{data.allegiance.monarch?.name && <div>Monarch: {data.allegiance.monarch.name}</div>}
|
||||
{data.allegiance.patron?.name && <div>Patron: {data.allegiance.patron.name}</div>}
|
||||
{data.allegiance.rank != null && <div>Rank: {data.allegiance.rank}</div>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
|
|
|
|||
197
frontend/src/components/windows/CombatStatsWindow.tsx
Normal file
197
frontend/src/components/windows/CombatStatsWindow.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
|
||||
const ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
|
||||
|
||||
function getDmg(side: any, atkType: string, el: string): number {
|
||||
return (side?.[atkType]?.[el]?.total_normal_damage ?? 0) + (side?.[atkType]?.[el]?.total_crit_damage ?? 0);
|
||||
}
|
||||
|
||||
function flatten(side: any) {
|
||||
let r = { attacks: 0, failed: 0, crits: 0, normalDmg: 0, maxNormal: 0, critDmg: 0, maxCrit: 0 };
|
||||
if (!side) return r;
|
||||
for (const byEl of Object.values(side) as any[]) {
|
||||
for (const s of Object.values(byEl) as any[]) {
|
||||
r.attacks += s.total_attacks ?? 0;
|
||||
r.failed += s.failed_attacks ?? 0;
|
||||
r.crits += s.crits ?? 0;
|
||||
r.normalDmg += s.total_normal_damage ?? 0;
|
||||
r.maxNormal = Math.max(r.maxNormal, s.max_normal_damage ?? 0);
|
||||
r.critDmg += s.total_crit_damage ?? 0;
|
||||
r.maxCrit = Math.max(r.maxCrit, s.max_crit_damage ?? 0);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function flattenType(side: any, type: string) {
|
||||
let r = { attacks: 0, failed: 0 };
|
||||
const byEl = side?.[type];
|
||||
if (!byEl) return r;
|
||||
for (const s of Object.values(byEl) as any[]) { r.attacks += s.total_attacks ?? 0; r.failed += s.failed_attacks ?? 0; }
|
||||
return r;
|
||||
}
|
||||
|
||||
export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [mode, setMode] = useState<'session' | 'lifetime'>('session');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
|
||||
const iv = setInterval(() => {
|
||||
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
|
||||
}, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [charName]);
|
||||
|
||||
const state = data?.[mode];
|
||||
const monsters = state?.monsters ?? {};
|
||||
const names = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
|
||||
|
||||
// Aggregate for selected or all
|
||||
const agg = useMemo(() => {
|
||||
let offense: any = {}, defense: any = {}, aeth = 0, cloak = 0;
|
||||
const list = selected ? [monsters[selected]].filter(Boolean) : names.map(n => monsters[n]);
|
||||
for (const m of list) {
|
||||
if (!m) continue;
|
||||
for (const [at, byEl] of Object.entries(m.offense ?? {})) {
|
||||
if (!offense[at]) offense[at] = {};
|
||||
for (const [el, s] of Object.entries(byEl as any)) {
|
||||
if (!offense[at][el]) offense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
|
||||
const t = offense[at][el]; const src = s as any;
|
||||
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; t.crits += src.crits ?? 0;
|
||||
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
|
||||
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
|
||||
}
|
||||
}
|
||||
for (const [at, byEl] of Object.entries(m.defense ?? {})) {
|
||||
if (!defense[at]) defense[at] = {};
|
||||
for (const [el, s] of Object.entries(byEl as any)) {
|
||||
if (!defense[at][el]) defense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
|
||||
const t = defense[at][el]; const src = s as any;
|
||||
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0;
|
||||
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
|
||||
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
|
||||
}
|
||||
}
|
||||
aeth += m.aetheria_surges ?? 0;
|
||||
cloak += m.cloak_surges ?? 0;
|
||||
}
|
||||
if (monsters['__cloak_surges__'] && !selected) cloak += monsters['__cloak_surges__'].cloak_surges ?? 0;
|
||||
return { offense, defense, aeth, cloak };
|
||||
}, [monsters, names, selected]);
|
||||
|
||||
const off = flatten(agg.offense);
|
||||
const defMM = flattenType(agg.defense, 'MeleeMissile');
|
||||
const defMag = flattenType(agg.defense, 'Magic');
|
||||
const hitRate = off.attacks > 0 ? ((off.attacks - off.failed) / off.attacks * 100).toFixed(0) : '0';
|
||||
const evadeRate = defMM.attacks > 0 ? (defMM.failed / defMM.attacks * 100).toFixed(0) : '0';
|
||||
const resistRate = defMag.attacks > 0 ? (defMag.failed / defMag.attacks * 100).toFixed(0) : '0';
|
||||
const hits = off.attacks - off.failed;
|
||||
const normalHits = hits - off.crits;
|
||||
const avgN = normalHits > 0 ? Math.round(off.normalDmg / normalHits) : 0;
|
||||
const avgC = off.crits > 0 ? Math.round(off.critDmg / off.crits) : 0;
|
||||
const critPct = hits > 0 ? (off.crits / hits * 100).toFixed(1) : '0';
|
||||
const fmtN = (n: number) => n === 0 ? '' : n.toLocaleString();
|
||||
|
||||
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' }}>
|
||||
<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>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Monster list (left) */}
|
||||
<div style={{ width: 240, borderRight: '1px solid #333', overflowY: 'auto', fontSize: '0.72rem' }}>
|
||||
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem', fontWeight: 600 }}>
|
||||
<span style={{ width: 14 }}></span><span style={{ flex: 1 }}>Monster</span>
|
||||
<span style={{ width: 40, textAlign: 'right' }}>Kills</span><span style={{ width: 55, textAlign: 'right' }}>Dmg</span>
|
||||
</div>
|
||||
{/* All row */}
|
||||
<div style={{ display: 'flex', padding: '3px 6px', cursor: 'pointer', background: selected === null ? '#2a3a4a' : '', borderBottom: '1px solid #222', color: '#ddd' }}
|
||||
onClick={() => setSelected(null)}>
|
||||
<span style={{ width: 14, color: '#888' }}>{selected === null ? '*' : ''}</span>
|
||||
<span style={{ flex: 1 }}>All</span>
|
||||
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(state?.total_kills ?? 0)}</span>
|
||||
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(state?.total_damage_given ?? 0)}</span>
|
||||
</div>
|
||||
{names.map(n => {
|
||||
const m = monsters[n];
|
||||
return (
|
||||
<div key={n} style={{ display: 'flex', padding: '2px 6px', cursor: 'pointer', background: selected === n ? '#2a3a4a' : '',
|
||||
borderBottom: '1px solid #1a1a1a', color: '#ccc' }} onClick={() => setSelected(n)}>
|
||||
<span style={{ width: 14, color: '#888' }}>{selected === n ? '*' : ''}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n}</span>
|
||||
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(m.kill_count)}</span>
|
||||
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(m.damage_given)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Breakdown grid (right) */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.72rem' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#777', fontSize: '0.65rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '1px 4px' }}></th>
|
||||
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given M/M</th>
|
||||
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given Mag</th>
|
||||
<th style={{ width: 4 }}></th>
|
||||
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv M/M</th>
|
||||
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv Mag</th>
|
||||
<th style={{ width: 4 }}></th>
|
||||
<th style={{ textAlign: 'left', padding: '1px 3px' }}>Stats</th>
|
||||
<th style={{ textAlign: 'right', padding: '1px 3px' }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ELEMENTS.map((el, i) => {
|
||||
const stats = [
|
||||
['Evades', defMM.attacks > 0 ? `${fmtN(defMM.attacks)} (${evadeRate}%)` : ''],
|
||||
['Resists', defMag.attacks > 0 ? `${fmtN(defMag.attacks)} (${resistRate}%)` : ''],
|
||||
['A.Surges', agg.aeth > 0 ? `${fmtN(agg.aeth)}` : ''],
|
||||
['C.Surges', agg.cloak > 0 ? `${fmtN(agg.cloak)}` : ''],
|
||||
['', ''], ['', ''],
|
||||
['Av/Mx', avgN > 0 ? `${fmtN(avgN)} / ${fmtN(off.maxNormal)}` : ''],
|
||||
['Crits', off.crits > 0 ? `${fmtN(off.crits)} (${critPct}%)` : ''],
|
||||
][i] ?? ['', ''];
|
||||
return (
|
||||
<tr key={el}>
|
||||
<td style={{ padding: '1px 4px', color: '#888' }}>{el}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'MeleeMissile', el))}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'Magic', el))}</td>
|
||||
<td></td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'MeleeMissile', el))}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'Magic', el))}</td>
|
||||
<td></td>
|
||||
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>{stats[0]}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{stats[1]}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td colSpan={9} style={{ height: 4 }}></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '1px 4px', color: '#888', fontWeight: 600 }}>Total</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))}</td>
|
||||
<td></td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))}</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))}</td>
|
||||
<td></td>
|
||||
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>Total</td>
|
||||
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(off.normalDmg + off.critDmg)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
94
frontend/src/components/windows/IssuesWindow.tsx
Normal file
94
frontend/src/components/windows/IssuesWindow.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Issue {
|
||||
id: number; title: string; description: string; category: string;
|
||||
created: string; resolved: boolean; author: string;
|
||||
comments?: Array<{ id: number; text: string; author: string; created: string }>;
|
||||
}
|
||||
|
||||
interface Props { id: string; zIndex: number; }
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
plugin: '#4488ff', overlord: '#44cc44', nav: '#ffaa00', macro: '#cc44cc', other: '#888',
|
||||
};
|
||||
|
||||
export const IssuesWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [category, setCategory] = useState('plugin');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ issues: Issue[] }>('/issues');
|
||||
setIssues((data.issues ?? []).sort((a, b) => (a.resolved ? 1 : 0) - (b.resolved ? 1 : 0)));
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const addIssue = async () => {
|
||||
if (!title.trim()) return;
|
||||
await fetch('/api/issues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ title: title.trim(), description: desc.trim(), category }) });
|
||||
setTitle(''); setDesc('');
|
||||
refresh();
|
||||
};
|
||||
|
||||
const toggleResolve = async (issue: Issue) => {
|
||||
await fetch(`/api/issues/${issue.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ resolved: !issue.resolved }) });
|
||||
refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title="Issues Board" zIndex={zIndex} width={540} height={520}>
|
||||
{/* Issue list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.75rem' }}>
|
||||
{issues.length === 0 ? (
|
||||
<div style={{ padding: 12, color: '#666', textAlign: 'center' }}>No issues</div>
|
||||
) : issues.map(issue => (
|
||||
<div key={issue.id} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f', borderRadius: 3,
|
||||
border: '1px solid #333', opacity: issue.resolved ? 0.5 : 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontSize: '0.6rem', padding: '1px 6px', borderRadius: 3,
|
||||
background: CAT_COLORS[issue.category] ?? '#888', color: '#111', fontWeight: 600 }}>
|
||||
{issue.category}
|
||||
</span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{issue.title}</span>
|
||||
<button onClick={() => toggleResolve(issue)} style={{ fontSize: '0.65rem', padding: '1px 6px',
|
||||
background: issue.resolved ? '#333' : 'rgba(68,204,68,0.15)', color: issue.resolved ? '#888' : '#4c4',
|
||||
border: '1px solid #444', borderRadius: 3, cursor: 'pointer' }}>
|
||||
{issue.resolved ? '↻ Reopen' : '✓ Resolve'}
|
||||
</button>
|
||||
</div>
|
||||
{issue.description && <div style={{ color: '#888', marginTop: 3, fontSize: '0.7rem' }}>{issue.description}</div>}
|
||||
<div style={{ color: '#555', fontSize: '0.6rem', marginTop: 2 }}>
|
||||
by {issue.author} · {new Date(issue.created).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Add issue form */}
|
||||
<div style={{ padding: 6, borderTop: '1px solid #333', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Issue title..."
|
||||
style={{ flex: 1, padding: '3px 6px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }} />
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}
|
||||
style={{ padding: '3px 4px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3 }}>
|
||||
<option value="plugin">Plugin</option><option value="overlord">Overlord</option>
|
||||
<option value="nav">Nav</option><option value="macro">Macro</option><option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} placeholder="Description..."
|
||||
rows={2} style={{ flex: 1, padding: '3px 6px', fontSize: '0.7rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, resize: 'vertical' }} />
|
||||
<button onClick={addIssue} style={{ padding: '4px 12px', background: 'rgba(68,136,255,0.15)', color: '#6aadff',
|
||||
border: '1px solid rgba(68,136,255,0.3)', borderRadius: 3, cursor: 'pointer', alignSelf: 'flex-end', fontSize: '0.7rem' }}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,52 +1,65 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
|
||||
interface NearbyObject {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
distance: number;
|
||||
bearing?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
socket: WebSocket | null;
|
||||
nearbyObjects: any[];
|
||||
nearbyObjects: NearbyObject[];
|
||||
}
|
||||
|
||||
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, nearbyObjects }) => {
|
||||
// Send start_radar when window opens, stop_radar when it closes
|
||||
// Send start_radar when window opens, stop_radar on close
|
||||
useEffect(() => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar start' }));
|
||||
socket.send(JSON.stringify({ player_name: charName, command: 'start_radar' }));
|
||||
}
|
||||
return () => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar stop' }));
|
||||
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));
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={450} height={400}>
|
||||
<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
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.73rem' }}>
|
||||
{objects.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666', textAlign: 'center' }}>
|
||||
Waiting for nearby objects data...<br/>
|
||||
<span style={{ fontSize: '0.65rem' }}>The plugin will start sending radar data shortly.</span>
|
||||
<div style={{ padding: 20, color: '#555', textAlign: 'center' }}>
|
||||
Waiting for radar data from plugin...
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Type</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dist</th>
|
||||
<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>
|
||||
{objects.map((obj: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 6px' }}>{obj.name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{obj.type || obj.object_class || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{obj.distance ? `${Math.round(obj.distance)}m` : ''}</td>
|
||||
{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>
|
||||
|
|
|
|||
74
frontend/src/components/windows/VitalSharingWindow.tsx
Normal file
74
frontend/src/components/windows/VitalSharingWindow.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Peer {
|
||||
character_name: string; plugin_connected: boolean; subscribed: boolean;
|
||||
tags: string[];
|
||||
vitals?: { current_health: number; max_health: number; current_stamina: number; max_stamina: number; current_mana: number; max_mana: number };
|
||||
position?: { ns: number; ew: number; z: number };
|
||||
}
|
||||
|
||||
interface Props { id: string; zIndex: number; }
|
||||
|
||||
export const VitalSharingWindow: React.FC<Props> = ({ id, zIndex }) => {
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ peers: Peer[] }>('/vital-sharing/peers');
|
||||
setPeers(data.peers ?? []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetch();
|
||||
const interval = setInterval(fetch, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const pct = (cur: number, max: number) => max > 0 ? Math.min(100, (cur / max) * 100) : 0;
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title="Vital Sharing Network" zIndex={zIndex} width={520} height={450}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.75rem' }}>
|
||||
{peers.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666', textAlign: 'center' }}>No vital-sharing peers connected</div>
|
||||
) : peers.map(p => (
|
||||
<div key={p.character_name} style={{ padding: '6px 8px', marginBottom: 4, background: '#1f1f1f',
|
||||
borderRadius: 3, border: '1px solid #333' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
|
||||
<span style={{ color: p.plugin_connected ? '#4c4' : '#a33', fontSize: '0.8rem' }}>●</span>
|
||||
<strong style={{ flex: 1 }}>{p.character_name}</strong>
|
||||
{p.subscribed && <span style={{ color: '#6bf', fontSize: '0.65rem' }}>[subscribed]</span>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: '0.68rem', marginBottom: 3 }}>
|
||||
tags: {p.tags?.join(', ') || 'none'}
|
||||
</div>
|
||||
{p.vitals && p.vitals.max_health > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{[
|
||||
{ label: 'HP', cur: p.vitals.current_health, max: p.vitals.max_health, bg: '#330000', fill: '#c44' },
|
||||
{ label: 'STA', cur: p.vitals.current_stamina, max: p.vitals.max_stamina, bg: '#331a00', fill: '#ca0' },
|
||||
{ label: 'MANA', cur: p.vitals.current_mana, max: p.vitals.max_mana, bg: '#001433', fill: '#48f' },
|
||||
].map(bar => (
|
||||
<div key={bar.label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 32, color: '#888', fontSize: '0.65rem' }}>{bar.label}</span>
|
||||
<div style={{ flex: 1, height: 6, background: bar.bg, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct(bar.cur, bar.max)}%`, height: '100%', background: bar.fill, borderRadius: 3 }} />
|
||||
</div>
|
||||
<span style={{ width: 60, textAlign: 'right', fontSize: '0.65rem', color: '#888' }}>{bar.cur}/{bar.max}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{p.position && (
|
||||
<div style={{ color: '#555', fontSize: '0.65rem', marginTop: 2 }}>
|
||||
{p.position.ns?.toFixed(1)}N, {p.position.ew?.toFixed(1)}E
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,6 +5,9 @@ import { StatsWindow } from './StatsWindow';
|
|||
import { CharacterWindow } from './CharacterWindow';
|
||||
import { InventoryWindow } from './InventoryWindow';
|
||||
import { RadarWindow } from './RadarWindow';
|
||||
import { CombatStatsWindow } from './CombatStatsWindow';
|
||||
import { IssuesWindow } from './IssuesWindow';
|
||||
import { VitalSharingWindow } from './VitalSharingWindow';
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -20,28 +23,30 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, sock
|
|||
<>
|
||||
{windows.map(w => {
|
||||
const charName = w.charName ?? '';
|
||||
const prefix = w.id.split('-')[0];
|
||||
|
||||
if (w.id.startsWith('chat-')) {
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
messages={chatMessages.get(charName) ?? []} socket={socket} />;
|
||||
switch (prefix) {
|
||||
case 'chat':
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
messages={chatMessages.get(charName) ?? []} socket={socket} />;
|
||||
case 'stats':
|
||||
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
case 'char':
|
||||
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':
|
||||
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
socket={socket} nearbyObjects={[]} />;
|
||||
case 'combat':
|
||||
return <CombatStatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
case 'issues':
|
||||
return <IssuesWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
case 'vitalsharing':
|
||||
return <VitalSharingWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (w.id.startsWith('stats-')) {
|
||||
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
if (w.id.startsWith('char-')) {
|
||||
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} />;
|
||||
}
|
||||
if (w.id.startsWith('radar-')) {
|
||||
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
socket={socket} nearbyObjects={[]} />;
|
||||
}
|
||||
if (w.id.startsWith('combat-')) {
|
||||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue