1877 lines
64 KiB
JavaScript
1877 lines
64 KiB
JavaScript
/*
|
||
* script.js - Frontend logic for Dereth Tracker Single-Page Application.
|
||
* Handles WebSocket communication, UI rendering of player lists, map display,
|
||
* and user interactions (filtering, sorting, chat, stats windows).
|
||
*/
|
||
/**
|
||
* script.js - Frontend controller for Dereth Tracker SPA
|
||
*
|
||
* Responsibilities:
|
||
* - Establish WebSocket connections to receive live telemetry and chat data
|
||
* - Fetch and render live player lists, trails, and map dots
|
||
* - Handle user interactions: filtering, sorting, selecting players
|
||
* - Manage dynamic UI components: chat windows, stats panels, tooltips
|
||
* - Provide smooth pan/zoom of map overlay using CSS transforms
|
||
*
|
||
* Structure:
|
||
* 1. DOM references and constant definitions
|
||
* 2. Color palette and assignment logic
|
||
* 3. Sorting and filtering setup
|
||
* 4. Utility functions (coordinate mapping, color hashing)
|
||
* 5. UI window creation (stats, chat)
|
||
* 6. Rendering functions for list and map
|
||
* 7. Event listeners for map interactions and WebSocket messages
|
||
*/
|
||
/* ---------- DOM references --------------------------------------- */
|
||
const wrap = document.getElementById('mapContainer');
|
||
const group = document.getElementById('mapGroup');
|
||
const img = document.getElementById('map');
|
||
const dots = document.getElementById('dots');
|
||
const trailsContainer = document.getElementById('trails');
|
||
const list = document.getElementById('playerList');
|
||
const btnContainer = document.getElementById('sortButtons');
|
||
const tooltip = document.getElementById('tooltip');
|
||
const coordinates = document.getElementById('coordinates');
|
||
|
||
// Global drag system to prevent event listener accumulation
|
||
let currentDragWindow = null;
|
||
let dragStartX = 0, dragStartY = 0, dragStartLeft = 0, dragStartTop = 0;
|
||
|
||
function makeDraggable(win, header) {
|
||
if (!window.__chatZ) window.__chatZ = 10000;
|
||
header.style.cursor = 'move';
|
||
|
||
const bringToFront = () => {
|
||
window.__chatZ += 1;
|
||
win.style.zIndex = window.__chatZ;
|
||
};
|
||
|
||
header.addEventListener('mousedown', e => {
|
||
if (e.target.closest('button')) return;
|
||
e.preventDefault();
|
||
currentDragWindow = win;
|
||
bringToFront();
|
||
dragStartX = e.clientX;
|
||
dragStartY = e.clientY;
|
||
dragStartLeft = win.offsetLeft;
|
||
dragStartTop = win.offsetTop;
|
||
document.body.classList.add('noselect');
|
||
});
|
||
|
||
// Touch support
|
||
header.addEventListener('touchstart', e => {
|
||
if (e.touches.length !== 1 || e.target.closest('button')) return;
|
||
currentDragWindow = win;
|
||
bringToFront();
|
||
const t = e.touches[0];
|
||
dragStartX = t.clientX;
|
||
dragStartY = t.clientY;
|
||
dragStartLeft = win.offsetLeft;
|
||
dragStartTop = win.offsetTop;
|
||
});
|
||
}
|
||
|
||
// Global mouse handlers (only added once)
|
||
window.addEventListener('mousemove', e => {
|
||
if (!currentDragWindow) return;
|
||
const dx = e.clientX - dragStartX;
|
||
const dy = e.clientY - dragStartY;
|
||
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
|
||
currentDragWindow.style.top = `${dragStartTop + dy}px`;
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
if (currentDragWindow) {
|
||
currentDragWindow = null;
|
||
document.body.classList.remove('noselect');
|
||
}
|
||
});
|
||
|
||
window.addEventListener('touchmove', e => {
|
||
if (!currentDragWindow || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
const dx = t.clientX - dragStartX;
|
||
const dy = t.clientY - dragStartY;
|
||
currentDragWindow.style.left = `${dragStartLeft + dx}px`;
|
||
currentDragWindow.style.top = `${dragStartTop + dy}px`;
|
||
});
|
||
|
||
window.addEventListener('touchend', () => {
|
||
currentDragWindow = null;
|
||
});
|
||
// Filter input for player names (starts-with filter)
|
||
let currentFilter = '';
|
||
const filterInput = document.getElementById('playerFilter');
|
||
if (filterInput) {
|
||
filterInput.addEventListener('input', e => {
|
||
currentFilter = e.target.value.toLowerCase().trim();
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
// WebSocket for chat and commands
|
||
let socket;
|
||
// Keep track of open chat windows: character_name -> DOM element
|
||
const chatWindows = {};
|
||
// Keep track of open stats windows: character_name -> DOM element
|
||
const statsWindows = {};
|
||
// Keep track of open inventory windows: character_name -> DOM element
|
||
const inventoryWindows = {};
|
||
|
||
/**
|
||
* ---------- Application Constants -----------------------------
|
||
* Defines key parameters for map rendering, data polling, and UI limits.
|
||
*
|
||
* MAX_Z: Maximum altitude difference considered (filter out outliers by Z)
|
||
* FOCUS_ZOOM: Zoom level when focusing on a selected character
|
||
* POLL_MS: Millisecond interval to fetch live player data and trails
|
||
* MAP_BOUNDS: World coordinate bounds for the game map (used for projection)
|
||
* API_BASE: Prefix for AJAX endpoints (set when behind a proxy)
|
||
* MAX_CHAT_LINES: Max number of lines per chat window to cap memory usage
|
||
* CHAT_COLOR_MAP: Color mapping for in-game chat channels by channel code
|
||
*/
|
||
/* ---------- constants ------------------------------------------- */
|
||
const MAX_Z = 20;
|
||
const FOCUS_ZOOM = 3; // zoom level when you click a name
|
||
const POLL_MS = 2000;
|
||
// UtilityBelt's more accurate coordinate bounds
|
||
const MAP_BOUNDS = {
|
||
west: -102.1,
|
||
east: 102.1,
|
||
north: 102.1,
|
||
south: -102.1
|
||
};
|
||
|
||
// Base path for tracker API endpoints; prefix API calls with '/api' when served behind a proxy
|
||
// If serving APIs at root, leave empty
|
||
const API_BASE = '';
|
||
// Maximum number of lines to retain in each chat window scrollback
|
||
const MAX_CHAT_LINES = 1000;
|
||
// Map numeric chat color codes to CSS hex colors
|
||
const CHAT_COLOR_MAP = {
|
||
0: '#00FF00', // Broadcast
|
||
2: '#FFFFFF', // Speech
|
||
3: '#FFD700', // Tell
|
||
4: '#CCCC00', // OutgoingTell
|
||
5: '#FF00FF', // System
|
||
6: '#FF0000', // Combat
|
||
7: '#00CCFF', // Magic
|
||
8: '#DDDDDD', // Channel
|
||
9: '#FF9999', // ChannelSend
|
||
10: '#FFFF33', // Social
|
||
11: '#CCFF33', // SocialSend
|
||
12: '#FFFFFF', // Emote
|
||
13: '#00FFFF', // Advancement
|
||
14: '#66CCFF', // Abuse
|
||
15: '#FF0000', // Help
|
||
16: '#33FF00', // Appraisal
|
||
17: '#0099FF', // Spellcasting
|
||
18: '#FF6600', // Allegiance
|
||
19: '#CC66FF', // Fellowship
|
||
20: '#00FF00', // WorldBroadcast
|
||
21: '#FF0000', // CombatEnemy
|
||
22: '#FF33CC', // CombatSelf
|
||
23: '#00CC00', // Recall
|
||
24: '#00FF00', // Craft
|
||
25: '#00FF66', // Salvaging
|
||
27: '#FFFFFF', // General
|
||
28: '#33FF33', // Trade
|
||
29: '#CCCCCC', // LFG
|
||
30: '#CC00CC', // Roleplay
|
||
31: '#FFFF00' // AdminTell
|
||
};
|
||
|
||
/* ---------- Heat Map Globals ---------- */
|
||
let heatmapCanvas, heatmapCtx;
|
||
let heatmapEnabled = false;
|
||
let heatmapData = null;
|
||
let heatTimeout = null;
|
||
const HEAT_PADDING = 50; // px beyond viewport to still draw
|
||
const HEAT_THROTTLE = 16; // ~60 fps
|
||
|
||
/**
|
||
* ---------- Player Color Assignment ----------------------------
|
||
* Uses a predefined accessible color palette for player dots to ensure
|
||
* high contrast and colorblind-friendly display. Once the palette
|
||
* is exhausted, falls back to a deterministic hash-to-hue function.
|
||
*/
|
||
/* ---------- player/dot color assignment ------------------------- */
|
||
// A base palette of distinct, color-blind-friendly colors
|
||
const PALETTE = [
|
||
// Original colorblind-friendly base palette
|
||
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
||
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
|
||
|
||
// Extended high-contrast colors
|
||
'#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff',
|
||
'#44ffff', '#ff8844', '#88ff44', '#4488ff', '#ff4488',
|
||
|
||
// Darker variants
|
||
'#cc3333', '#33cc33', '#3333cc', '#cccc33', '#cc33cc',
|
||
'#33cccc', '#cc6633', '#66cc33', '#3366cc', '#cc3366',
|
||
|
||
// Brighter variants
|
||
'#ff6666', '#66ff66', '#6666ff', '#ffff66', '#ff66ff',
|
||
'#66ffff', '#ffaa66', '#aaff66', '#66aaff', '#ff66aa',
|
||
|
||
// Additional distinct colors
|
||
'#990099', '#009900', '#000099', '#990000', '#009999',
|
||
'#999900', '#aa5500', '#55aa00', '#0055aa', '#aa0055',
|
||
|
||
// Light pastels for contrast
|
||
'#ffaaaa', '#aaffaa', '#aaaaff', '#ffffaa', '#ffaaff',
|
||
'#aaffff', '#ffccaa', '#ccffaa', '#aaccff', '#ffaacc'
|
||
];
|
||
// Map from character name to assigned color
|
||
const colorMap = {};
|
||
// Next index to pick from PALETTE
|
||
let nextPaletteIndex = 0;
|
||
/**
|
||
* Assigns or returns a consistent color for a given name.
|
||
* Uses a fixed palette first, then falls back to hue hashing.
|
||
*/
|
||
function getColorFor(name) {
|
||
if (colorMap[name]) {
|
||
return colorMap[name];
|
||
}
|
||
let color;
|
||
if (nextPaletteIndex < PALETTE.length) {
|
||
color = PALETTE[nextPaletteIndex++];
|
||
} else {
|
||
// Fallback: hash to HSL hue
|
||
color = hue(name);
|
||
}
|
||
colorMap[name] = color;
|
||
return color;
|
||
}
|
||
|
||
/*
|
||
* ---------- Sort Configuration -------------------------------
|
||
* Defines available sort criteria for the active player list:
|
||
* - name: alphabetical ascending
|
||
* - kph: kills per hour descending
|
||
* - kills: total kills descending
|
||
* - rares: rare events found during current session descending
|
||
* Each option includes a label for UI display and a comparator function.
|
||
*/
|
||
/* ---------- sort configuration ---------------------------------- */
|
||
const sortOptions = [
|
||
{
|
||
value: "name",
|
||
label: "Name",
|
||
comparator: (a, b) => a.character_name.localeCompare(b.character_name)
|
||
},
|
||
{
|
||
value: "kph",
|
||
label: "KPH",
|
||
comparator: (a, b) => b.kills_per_hour - a.kills_per_hour
|
||
},
|
||
{
|
||
value: "kills",
|
||
label: "S.Kills",
|
||
comparator: (a, b) => b.kills - a.kills
|
||
},
|
||
{
|
||
value: "rares",
|
||
label: "S.Rares",
|
||
comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
|
||
},
|
||
{
|
||
value: "total_kills",
|
||
label: "T.Kills",
|
||
comparator: (a, b) => (b.total_kills || 0) - (a.total_kills || 0)
|
||
},
|
||
{
|
||
value: "kpr",
|
||
label: "KPR",
|
||
comparator: (a, b) => {
|
||
const aKpr = (a.total_rares || 0) > 0 ? (a.total_kills || 0) / (a.total_rares || 0) : Infinity;
|
||
const bKpr = (b.total_rares || 0) > 0 ? (b.total_kills || 0) / (b.total_rares || 0) : Infinity;
|
||
return aKpr - bKpr; // Ascending - lower KPR is better (more efficient rare finding)
|
||
}
|
||
}
|
||
];
|
||
|
||
let currentSort = sortOptions[0];
|
||
let currentPlayers = [];
|
||
|
||
/* ---------- generate segmented buttons -------------------------- */
|
||
sortOptions.forEach(opt => {
|
||
const btn = document.createElement('div');
|
||
btn.className = 'btn';
|
||
btn.textContent = opt.label;
|
||
btn.dataset.value = opt.value;
|
||
if (opt.value === currentSort.value) btn.classList.add('active');
|
||
|
||
btn.addEventListener('click', () => {
|
||
btnContainer.querySelectorAll('.btn')
|
||
.forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
currentSort = opt;
|
||
renderList();
|
||
});
|
||
|
||
btnContainer.appendChild(btn);
|
||
});
|
||
|
||
/* ---------- map & state variables ------------------------------- */
|
||
let imgW = 0, imgH = 0;
|
||
let scale = 1, offX = 0, offY = 0, minScale = 1;
|
||
let dragging = false, sx = 0, sy = 0;
|
||
let selected = "";
|
||
let pollID = null;
|
||
|
||
/* ---------- utility functions ----------------------------------- */
|
||
const hue = name => {
|
||
let h = 0;
|
||
for (let c of name) h = c.charCodeAt(0) + ((h << 5) - h);
|
||
return `hsl(${Math.abs(h) % 360},72%,50%)`;
|
||
};
|
||
|
||
const loc = (ns, ew) =>
|
||
`${Math.abs(ns).toFixed(1)}${ns>=0?"N":"S"} `
|
||
+ `${Math.abs(ew).toFixed(1)}${ew>=0?"E":"W"}`;
|
||
|
||
function worldToPx(ew, ns) {
|
||
const x = ((ew - MAP_BOUNDS.west)
|
||
/ (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
|
||
const y = ((MAP_BOUNDS.north - ns)
|
||
/ (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
|
||
return { x, y };
|
||
}
|
||
|
||
function pxToWorld(x, y) {
|
||
// Convert screen coordinates to map image coordinates
|
||
const mapX = (x - offX) / scale;
|
||
const mapY = (y - offY) / scale;
|
||
|
||
// Convert map image coordinates to world coordinates
|
||
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
|
||
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
|
||
|
||
return { ew, ns };
|
||
}
|
||
|
||
/* ---------- Heat Map Functions ---------- */
|
||
|
||
function initHeatMap() {
|
||
heatmapCanvas = document.getElementById('heatmapCanvas');
|
||
if (!heatmapCanvas) {
|
||
console.error('Heat map canvas not found');
|
||
return;
|
||
}
|
||
|
||
heatmapCtx = heatmapCanvas.getContext('2d');
|
||
|
||
const toggle = document.getElementById('heatmapToggle');
|
||
if (toggle) {
|
||
toggle.addEventListener('change', e => {
|
||
heatmapEnabled = e.target.checked;
|
||
if (heatmapEnabled) {
|
||
fetchHeatmapData();
|
||
} else {
|
||
clearHeatmap();
|
||
}
|
||
});
|
||
}
|
||
|
||
window.addEventListener('resize', debounce(() => {
|
||
if (heatmapEnabled && heatmapData) {
|
||
renderHeatmap();
|
||
}
|
||
}, 250));
|
||
}
|
||
|
||
async function fetchHeatmapData() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/spawns/heatmap?hours=24&limit=50000`);
|
||
if (!response.ok) {
|
||
throw new Error(`Heat map API error: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
heatmapData = data.spawn_points; // [{ew, ns, intensity}]
|
||
console.log(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`);
|
||
renderHeatmap();
|
||
} catch (err) {
|
||
console.error('Failed to fetch heat map data:', err);
|
||
}
|
||
}
|
||
|
||
function renderHeatmap() {
|
||
if (!heatmapEnabled || !heatmapData || !heatmapCanvas || !imgW || !imgH) {
|
||
return;
|
||
}
|
||
|
||
// Set canvas size to match map dimensions (1:1 DPI)
|
||
heatmapCanvas.width = imgW;
|
||
heatmapCanvas.height = imgH;
|
||
heatmapCtx.clearRect(0, 0, imgW, imgH);
|
||
|
||
// Current visible map rect in px for viewport culling
|
||
const vw = wrap.clientWidth;
|
||
const vh = wrap.clientHeight;
|
||
const viewL = -offX / scale;
|
||
const viewT = -offY / scale;
|
||
const viewR = viewL + vw / scale;
|
||
const viewB = viewT + vh / scale;
|
||
|
||
// Render heat map points with viewport culling
|
||
for (const point of heatmapData) {
|
||
const { x, y } = worldToPx(point.ew, point.ns);
|
||
|
||
// Skip points outside visible area (with padding for smooth edges)
|
||
if (x < viewL - HEAT_PADDING || x > viewR + HEAT_PADDING ||
|
||
y < viewT - HEAT_PADDING || y > viewB + HEAT_PADDING) {
|
||
continue;
|
||
}
|
||
|
||
// Smaller, more precise spots to clearly show individual spawn locations
|
||
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
|
||
|
||
// Sharp gradient with distinct boundaries between spawn points
|
||
const gradient = heatmapCtx.createRadialGradient(x, y, 0, x, y, radius);
|
||
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`); // Bright red center
|
||
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`); // Quick fade to orange
|
||
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
|
||
|
||
heatmapCtx.fillStyle = gradient;
|
||
heatmapCtx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||
}
|
||
}
|
||
|
||
function clearHeatmap() {
|
||
if (heatmapCtx && heatmapCanvas) {
|
||
heatmapCtx.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
|
||
}
|
||
}
|
||
|
||
function debounce(fn, ms) {
|
||
let timeout;
|
||
return (...args) => {
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(() => fn(...args), ms);
|
||
};
|
||
}
|
||
|
||
// Show or create a stats window for a character
|
||
function showStatsWindow(name) {
|
||
if (statsWindows[name]) {
|
||
const existing = statsWindows[name];
|
||
// Toggle: close if already visible, open if hidden
|
||
if (existing.style.display === 'flex') {
|
||
existing.style.display = 'none';
|
||
} else {
|
||
existing.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
const win = document.createElement('div');
|
||
win.className = 'stats-window';
|
||
win.dataset.character = name;
|
||
// Header (reuses chat-header styling)
|
||
const header = document.createElement('div');
|
||
header.className = 'chat-header';
|
||
const title = document.createElement('span');
|
||
title.textContent = `Stats: ${name}`;
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'chat-close-btn';
|
||
closeBtn.textContent = '×';
|
||
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
|
||
header.appendChild(title);
|
||
header.appendChild(closeBtn);
|
||
win.appendChild(header);
|
||
// Time period controls
|
||
const controls = document.createElement('div');
|
||
controls.className = 'stats-controls';
|
||
const timeRanges = [
|
||
{ label: '1H', value: 'now-1h' },
|
||
{ label: '6H', value: 'now-6h' },
|
||
{ label: '24H', value: 'now-24h' },
|
||
{ label: '7D', value: 'now-7d' }
|
||
];
|
||
timeRanges.forEach(range => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'time-range-btn';
|
||
btn.textContent = range.label;
|
||
if (range.value === 'now-24h') btn.classList.add('active');
|
||
btn.addEventListener('click', () => {
|
||
controls.querySelectorAll('.time-range-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
updateStatsTimeRange(content, name, range.value);
|
||
});
|
||
controls.appendChild(btn);
|
||
});
|
||
win.appendChild(controls);
|
||
|
||
// Content container
|
||
const content = document.createElement('div');
|
||
content.className = 'chat-messages';
|
||
content.textContent = 'Loading stats...';
|
||
win.appendChild(content);
|
||
document.body.appendChild(win);
|
||
statsWindows[name] = win;
|
||
// Load initial stats with default 24h range
|
||
updateStatsTimeRange(content, name, 'now-24h');
|
||
// Enable dragging using the global drag system
|
||
makeDraggable(win, header);
|
||
}
|
||
|
||
function updateStatsTimeRange(content, name, timeRange) {
|
||
content.innerHTML = '';
|
||
const panels = [
|
||
{ title: 'Kills per Hour', id: 1 },
|
||
{ title: 'Memory (MB)', id: 2 },
|
||
{ title: 'CPU (%)', id: 3 },
|
||
{ title: 'Mem Handles', id: 4 }
|
||
];
|
||
panels.forEach(p => {
|
||
const iframe = document.createElement('iframe');
|
||
iframe.src =
|
||
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard` +
|
||
`?panelId=${p.id}` +
|
||
`&var-character=${encodeURIComponent(name)}` +
|
||
`&from=${timeRange}` +
|
||
`&to=now` +
|
||
`&theme=light`;
|
||
iframe.setAttribute('title', p.title);
|
||
iframe.width = '350';
|
||
iframe.height = '200';
|
||
iframe.frameBorder = '0';
|
||
iframe.allowFullscreen = true;
|
||
content.appendChild(iframe);
|
||
});
|
||
}
|
||
|
||
// Show or create an inventory window for a character
|
||
function showInventoryWindow(name) {
|
||
if (inventoryWindows[name]) {
|
||
const existing = inventoryWindows[name];
|
||
// Toggle: close if already visible, open if hidden
|
||
if (existing.style.display === 'flex') {
|
||
existing.style.display = 'none';
|
||
} else {
|
||
existing.style.display = 'flex';
|
||
}
|
||
return;
|
||
}
|
||
const win = document.createElement('div');
|
||
win.className = 'inventory-window';
|
||
win.dataset.character = name;
|
||
// Header (reuses chat-header styling)
|
||
const header = document.createElement('div');
|
||
header.className = 'chat-header';
|
||
const title = document.createElement('span');
|
||
title.textContent = `Inventory: ${name}`;
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'chat-close-btn';
|
||
closeBtn.textContent = '×';
|
||
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
|
||
header.appendChild(title);
|
||
header.appendChild(closeBtn);
|
||
win.appendChild(header);
|
||
// Loading message
|
||
const loading = document.createElement('div');
|
||
loading.className = 'inventory-loading';
|
||
loading.textContent = 'Loading inventory...';
|
||
win.appendChild(loading);
|
||
|
||
// Content container
|
||
const content = document.createElement('div');
|
||
content.className = 'inventory-content';
|
||
content.style.display = 'none';
|
||
win.appendChild(content);
|
||
|
||
// Fetch inventory data from main app (which will proxy to inventory service)
|
||
fetch(`${API_BASE}/inventory/${encodeURIComponent(name)}?limit=1000`)
|
||
.then(response => {
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
loading.style.display = 'none';
|
||
content.style.display = 'block';
|
||
|
||
// Create inventory grid
|
||
const grid = document.createElement('div');
|
||
grid.className = 'inventory-grid';
|
||
|
||
// Render each item
|
||
data.items.forEach(item => {
|
||
|
||
const slot = document.createElement('div');
|
||
slot.className = 'inventory-slot';
|
||
|
||
// Create layered icon container
|
||
const iconContainer = document.createElement('div');
|
||
iconContainer.className = 'item-icon-composite';
|
||
|
||
// Get base icon ID with portal.dat offset
|
||
const baseIconId = (item.icon + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
|
||
// Check for overlay and underlay from enhanced format or legacy format
|
||
let overlayIconId = null;
|
||
let underlayIconId = null;
|
||
|
||
// Enhanced format (inventory service) - check for proper icon overlay/underlay properties
|
||
if (item.icon_overlay_id && item.icon_overlay_id > 0) {
|
||
overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
|
||
if (item.icon_underlay_id && item.icon_underlay_id > 0) {
|
||
underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
|
||
// Fallback: Enhanced format (inventory service) - check spells object for decal info
|
||
if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') {
|
||
// Icon overlay (using the actual property names from the data)
|
||
// Only use valid icon IDs (must be > 100 to avoid invalid small IDs)
|
||
if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) {
|
||
overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
|
||
// Icon underlay
|
||
if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) {
|
||
underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
} else if (item.item_data) {
|
||
// Legacy format - parse item_data
|
||
try {
|
||
const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data;
|
||
|
||
if (itemData.IntValues) {
|
||
// Icon overlay (ID 218103849) - only use valid icon IDs
|
||
if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
|
||
overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
|
||
// Icon underlay (ID 218103850) - only use valid icon IDs
|
||
if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) {
|
||
underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to parse item data for', item.name);
|
||
}
|
||
}
|
||
|
||
// Create underlay (bottom layer)
|
||
if (underlayIconId) {
|
||
const underlayImg = document.createElement('img');
|
||
underlayImg.className = 'icon-underlay';
|
||
underlayImg.src = `/icons/${underlayIconId}.png`;
|
||
underlayImg.alt = 'underlay';
|
||
underlayImg.onerror = function() { this.style.display = 'none'; };
|
||
iconContainer.appendChild(underlayImg);
|
||
}
|
||
|
||
// Create base icon (middle layer)
|
||
const baseImg = document.createElement('img');
|
||
baseImg.className = 'icon-base';
|
||
baseImg.src = `/icons/${baseIconId}.png`;
|
||
baseImg.alt = item.name || 'Unknown Item';
|
||
baseImg.onerror = function() {
|
||
// Final fallback
|
||
this.src = '/icons/06000133.png';
|
||
};
|
||
iconContainer.appendChild(baseImg);
|
||
|
||
// Create overlay (top layer)
|
||
if (overlayIconId) {
|
||
const overlayImg = document.createElement('img');
|
||
overlayImg.className = 'icon-overlay';
|
||
overlayImg.src = `/icons/${overlayIconId}.png`;
|
||
overlayImg.alt = 'overlay';
|
||
overlayImg.onerror = function() { this.style.display = 'none'; };
|
||
iconContainer.appendChild(overlayImg);
|
||
}
|
||
|
||
// Create tooltip data
|
||
slot.dataset.name = item.name || 'Unknown Item';
|
||
slot.dataset.value = item.value || 0;
|
||
slot.dataset.burden = item.burden || 0;
|
||
|
||
// Store enhanced data for tooltips
|
||
// All data now comes from inventory service (no more local fallback)
|
||
if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
|
||
// Inventory service provides clean, structured data with translations
|
||
// Only include properties that actually exist on the item
|
||
const enhancedData = {};
|
||
|
||
// Check all possible enhanced properties from inventory service
|
||
const possibleProps = [
|
||
'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus',
|
||
'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name',
|
||
'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks',
|
||
'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating',
|
||
'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells',
|
||
'enhanced_properties', 'damage_range', 'damage_type', 'min_damage',
|
||
'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana',
|
||
'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus',
|
||
'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id'
|
||
];
|
||
|
||
// Only add properties that exist and have meaningful values
|
||
possibleProps.forEach(prop => {
|
||
if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
|
||
enhancedData[prop] = item[prop];
|
||
}
|
||
});
|
||
|
||
slot.dataset.enhancedData = JSON.stringify(enhancedData);
|
||
} else {
|
||
// No enhanced data available
|
||
slot.dataset.enhancedData = JSON.stringify({});
|
||
}
|
||
|
||
// Add tooltip on hover
|
||
slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot));
|
||
slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot));
|
||
slot.addEventListener('mouseleave', hideInventoryTooltip);
|
||
|
||
slot.appendChild(iconContainer);
|
||
grid.appendChild(slot);
|
||
});
|
||
|
||
content.appendChild(grid);
|
||
|
||
// Add item count
|
||
const count = document.createElement('div');
|
||
count.className = 'inventory-count';
|
||
count.textContent = `${data.item_count} items`;
|
||
content.appendChild(count);
|
||
})
|
||
.catch(err => {
|
||
loading.textContent = `Failed to load inventory: ${err.message}`;
|
||
console.error('Inventory fetch failed:', err);
|
||
});
|
||
|
||
document.body.appendChild(win);
|
||
inventoryWindows[name] = win;
|
||
|
||
// Enable dragging using the global drag system
|
||
makeDraggable(win, header);
|
||
}
|
||
|
||
// Inventory tooltip functions
|
||
let inventoryTooltip = null;
|
||
|
||
function showInventoryTooltip(e, slot) {
|
||
if (!inventoryTooltip) {
|
||
inventoryTooltip = document.createElement('div');
|
||
inventoryTooltip.className = 'inventory-tooltip';
|
||
document.body.appendChild(inventoryTooltip);
|
||
}
|
||
|
||
const name = slot.dataset.name;
|
||
const value = parseInt(slot.dataset.value) || 0;
|
||
const burden = parseInt(slot.dataset.burden) || 0;
|
||
|
||
// Build enhanced tooltip
|
||
let tooltipHTML = `<div class="tooltip-name">${name}</div>`;
|
||
|
||
// Basic stats
|
||
tooltipHTML += `<div class="tooltip-section">`;
|
||
tooltipHTML += `<div class="tooltip-value">Value: ${value.toLocaleString()}</div>`;
|
||
tooltipHTML += `<div class="tooltip-burden">Burden: ${burden}</div>`;
|
||
|
||
// Add workmanship right after basic stats for weapons/items
|
||
if (slot.dataset.enhancedData) {
|
||
try {
|
||
const enhanced = JSON.parse(slot.dataset.enhancedData);
|
||
if (enhanced.workmanship_text) {
|
||
tooltipHTML += `<div class="tooltip-workmanship">Workmanship: ${enhanced.workmanship_text}</div>`;
|
||
}
|
||
} catch (e) {
|
||
// Ignore parsing errors for this section
|
||
}
|
||
}
|
||
|
||
tooltipHTML += `</div>`;
|
||
|
||
// Enhanced data from inventory service
|
||
if (slot.dataset.enhancedData) {
|
||
try {
|
||
const enhanced = JSON.parse(slot.dataset.enhancedData);
|
||
|
||
// Only show enhanced sections if we have enhanced data
|
||
if (Object.keys(enhanced).length > 0) {
|
||
|
||
// Helper function to check for valid values
|
||
const isValid = (val) => val !== undefined && val !== null && val !== -1 && val !== -1.0;
|
||
|
||
// Weapon-specific stats section (for weapons)
|
||
if (enhanced.damage_range || enhanced.speed_text || enhanced.equip_skill_name) {
|
||
tooltipHTML += `<div class="tooltip-section">`;
|
||
|
||
// Skill requirement
|
||
if (enhanced.equip_skill_name) {
|
||
tooltipHTML += `<div class="tooltip-property">Skill: ${enhanced.equip_skill_name}</div>`;
|
||
}
|
||
|
||
// Damage with type
|
||
if (enhanced.damage_range && enhanced.damage_type) {
|
||
const damageText = enhanced.damage_type !== 'Physical' ?
|
||
`${enhanced.damage_range}, ${enhanced.damage_type}` :
|
||
enhanced.damage_range;
|
||
tooltipHTML += `<div class="tooltip-property">Damage: ${damageText}</div>`;
|
||
}
|
||
|
||
// Speed
|
||
if (enhanced.speed_text) {
|
||
tooltipHTML += `<div class="tooltip-property">Speed: ${enhanced.speed_text}</div>`;
|
||
}
|
||
|
||
// Attack and defense bonuses (as percentages)
|
||
if (isValid(enhanced.attack_bonus)) {
|
||
const attackPercent = ((enhanced.attack_bonus - 1) * 100).toFixed(1);
|
||
if (attackPercent !== "0.0") {
|
||
tooltipHTML += `<div class="tooltip-property">Bonus to Attack Skill: ${attackPercent > 0 ? '+' : ''}${attackPercent}%</div>`;
|
||
}
|
||
}
|
||
|
||
// Defense bonuses
|
||
if (enhanced.melee_defense_bonus && isValid(enhanced.melee_defense_bonus)) {
|
||
const defensePercent = ((enhanced.melee_defense_bonus - 1) * 100).toFixed(1);
|
||
if (defensePercent !== "0.0") {
|
||
tooltipHTML += `<div class="tooltip-property">Bonus to Melee Defense: ${defensePercent > 0 ? '+' : ''}${defensePercent}%</div>`;
|
||
}
|
||
}
|
||
|
||
// Magic defense bonus
|
||
if (enhanced.magic_defense_bonus && isValid(enhanced.magic_defense_bonus)) {
|
||
const magicDefensePercent = ((enhanced.magic_defense_bonus - 1) * 100).toFixed(1);
|
||
if (magicDefensePercent !== "0.0") {
|
||
tooltipHTML += `<div class="tooltip-property">Bonus to Magic Defense: ${magicDefensePercent > 0 ? '+' : ''}${magicDefensePercent}%</div>`;
|
||
}
|
||
}
|
||
|
||
// Elemental damage vs monsters
|
||
if (enhanced.elemental_damage_vs_monsters && isValid(enhanced.elemental_damage_vs_monsters)) {
|
||
const elementalPercent = ((enhanced.elemental_damage_vs_monsters - 1) * 100).toFixed(1);
|
||
if (elementalPercent !== "0.0") {
|
||
tooltipHTML += `<div class="tooltip-property">Elemental Damage vs Monsters: ${elementalPercent > 0 ? '+' : ''}${elementalPercent}%</div>`;
|
||
}
|
||
}
|
||
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Traditional combat stats section (for non-weapons or additional stats)
|
||
const combatProps = [];
|
||
if (isValid(enhanced.armor_level)) combatProps.push(`Armor Level: ${enhanced.armor_level}`);
|
||
if (!enhanced.damage_range && isValid(enhanced.max_damage)) combatProps.push(`Max Damage: ${enhanced.max_damage}`);
|
||
if (!enhanced.attack_bonus && isValid(enhanced.damage_bonus)) combatProps.push(`Damage Bonus: ${enhanced.damage_bonus.toFixed(1)}`);
|
||
|
||
if (combatProps.length > 0) {
|
||
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Combat Stats</div>`;
|
||
combatProps.forEach(prop => {
|
||
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
|
||
});
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Requirements section
|
||
const reqProps = [];
|
||
if (isValid(enhanced.wield_level)) reqProps.push(`Level Required: ${enhanced.wield_level}`);
|
||
if (isValid(enhanced.skill_level)) reqProps.push(`Skill Level: ${enhanced.skill_level}`);
|
||
if (enhanced.equip_skill_name) reqProps.push(`Skill: ${enhanced.equip_skill_name}`);
|
||
if (isValid(enhanced.lore_requirement)) reqProps.push(`Lore: ${enhanced.lore_requirement}`);
|
||
|
||
if (reqProps.length > 0) {
|
||
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Requirements</div>`;
|
||
reqProps.forEach(prop => {
|
||
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
|
||
});
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Enhancement section
|
||
const enhanceProps = [];
|
||
if (enhanced.material_name) enhanceProps.push(`Material: ${enhanced.material_name}`);
|
||
if (enhanced.imbue) enhanceProps.push(`Imbue: ${enhanced.imbue}`);
|
||
if (enhanced.item_set) enhanceProps.push(`Set: ${enhanced.item_set}`);
|
||
if (isValid(enhanced.tinks)) enhanceProps.push(`Tinks: ${enhanced.tinks}`);
|
||
// Use workmanship_text if available, otherwise numeric value
|
||
if (enhanced.workmanship_text) {
|
||
enhanceProps.push(`Workmanship: ${enhanced.workmanship_text}`);
|
||
} else if (isValid(enhanced.workmanship)) {
|
||
enhanceProps.push(`Workmanship: ${enhanced.workmanship.toFixed(1)}`);
|
||
}
|
||
|
||
if (enhanceProps.length > 0) {
|
||
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Enhancements</div>`;
|
||
enhanceProps.forEach(prop => {
|
||
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
|
||
});
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Ratings section
|
||
const ratingProps = [];
|
||
if (isValid(enhanced.damage_rating)) ratingProps.push(`Damage Rating: ${enhanced.damage_rating}`);
|
||
if (isValid(enhanced.crit_rating)) ratingProps.push(`Crit Rating: ${enhanced.crit_rating}`);
|
||
if (isValid(enhanced.heal_boost_rating)) ratingProps.push(`Heal Boost: ${enhanced.heal_boost_rating}`);
|
||
|
||
if (ratingProps.length > 0) {
|
||
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Ratings</div>`;
|
||
ratingProps.forEach(prop => {
|
||
tooltipHTML += `<div class="tooltip-property">${prop}</div>`;
|
||
});
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Spells section (condensed list)
|
||
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
|
||
const spellNames = enhanced.spells.spells.map(spell => spell.name).join(', ');
|
||
tooltipHTML += `<div class="tooltip-section">`;
|
||
tooltipHTML += `<div class="tooltip-property">Spells: ${spellNames}</div>`;
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Mana and Spellcraft section
|
||
if (enhanced.mana_display || enhanced.spellcraft) {
|
||
tooltipHTML += `<div class="tooltip-section">`;
|
||
if (enhanced.spellcraft) {
|
||
tooltipHTML += `<div class="tooltip-property">Spellcraft: ${enhanced.spellcraft}</div>`;
|
||
}
|
||
if (enhanced.mana_display) {
|
||
tooltipHTML += `<div class="tooltip-property">Mana: ${enhanced.mana_display}</div>`;
|
||
}
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Detailed Spell Descriptions section
|
||
if (enhanced.spells && enhanced.spells.spells && enhanced.spells.spells.length > 0) {
|
||
tooltipHTML += `<div class="tooltip-section"><div class="tooltip-section-title">Spell Descriptions</div>`;
|
||
enhanced.spells.spells.forEach(spell => {
|
||
tooltipHTML += `<div class="tooltip-spell">`;
|
||
tooltipHTML += `<div class="spell-name">${spell.name}</div>`;
|
||
if (spell.description) {
|
||
tooltipHTML += `<div class="spell-description">${spell.description}</div>`;
|
||
}
|
||
tooltipHTML += `</div>`;
|
||
});
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
// Object class info
|
||
if (enhanced.object_class_name) {
|
||
tooltipHTML += `<div class="tooltip-section">`;
|
||
tooltipHTML += `<div class="tooltip-info">Type: ${enhanced.object_class_name}</div>`;
|
||
tooltipHTML += `</div>`;
|
||
}
|
||
|
||
} // End of enhanced data check
|
||
|
||
} catch (e) {
|
||
console.warn('Failed to parse enhanced tooltip data', e);
|
||
}
|
||
}
|
||
|
||
inventoryTooltip.innerHTML = tooltipHTML;
|
||
|
||
// Position tooltip near cursor
|
||
const x = e.clientX + 10;
|
||
const y = e.clientY + 10;
|
||
inventoryTooltip.style.left = `${x}px`;
|
||
inventoryTooltip.style.top = `${y}px`;
|
||
inventoryTooltip.style.display = 'block';
|
||
}
|
||
|
||
function hideInventoryTooltip() {
|
||
if (inventoryTooltip) {
|
||
inventoryTooltip.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
|
||
const applyTransform = () =>
|
||
group.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`;
|
||
|
||
function clampPan() {
|
||
if (!imgW) return;
|
||
const r = wrap.getBoundingClientRect();
|
||
const vw = r.width, vh = r.height;
|
||
const mw = imgW * scale, mh = imgH * scale;
|
||
|
||
offX = mw <= vw ? (vw - mw) / 2 : Math.min(0, Math.max(vw - mw, offX));
|
||
offY = mh <= vh ? (vh - mh) / 2 : Math.min(0, Math.max(vh - mh, offY));
|
||
}
|
||
|
||
function updateView() {
|
||
clampPan();
|
||
applyTransform();
|
||
|
||
// Throttled heat map re-rendering during pan/zoom
|
||
if (heatmapEnabled && heatmapData && !heatTimeout) {
|
||
heatTimeout = setTimeout(() => {
|
||
renderHeatmap();
|
||
heatTimeout = null;
|
||
}, HEAT_THROTTLE);
|
||
}
|
||
}
|
||
|
||
function fitToWindow() {
|
||
const r = wrap.getBoundingClientRect();
|
||
scale = Math.min(r.width / imgW, r.height / imgH);
|
||
minScale = scale;
|
||
updateView();
|
||
}
|
||
|
||
/* ---------- tooltip handlers ------------------------------------ */
|
||
function showTooltip(evt, p) {
|
||
tooltip.textContent = `${p.character_name} — Kills: ${p.kills} - Kills/h: ${p.kills_per_hour}`;
|
||
const r = wrap.getBoundingClientRect();
|
||
tooltip.style.left = `${evt.clientX - r.left + 10}px`;
|
||
tooltip.style.top = `${evt.clientY - r.top + 10}px`;
|
||
tooltip.style.display = 'block';
|
||
}
|
||
function hideTooltip() {
|
||
tooltip.style.display = 'none';
|
||
}
|
||
|
||
/* ---------- polling and initialization -------------------------- */
|
||
async function pollLive() {
|
||
try {
|
||
const [liveRes, trailsRes] = await Promise.all([
|
||
fetch(`${API_BASE}/live/`),
|
||
fetch(`${API_BASE}/trails/?seconds=600`),
|
||
]);
|
||
const { players } = await liveRes.json();
|
||
const { trails } = await trailsRes.json();
|
||
currentPlayers = players;
|
||
renderTrails(trails);
|
||
renderList();
|
||
} catch (e) {
|
||
console.error('Live or trails fetch failed:', e);
|
||
}
|
||
}
|
||
|
||
async function pollTotalRares() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/total-rares/`);
|
||
const data = await response.json();
|
||
updateTotalRaresDisplay(data);
|
||
} catch (e) {
|
||
console.error('Total rares fetch failed:', e);
|
||
}
|
||
}
|
||
|
||
function updateTotalRaresDisplay(data) {
|
||
const countElement = document.getElementById('totalRaresCount');
|
||
if (countElement && data.all_time !== undefined && data.today !== undefined) {
|
||
const allTimeFormatted = data.all_time.toLocaleString();
|
||
const todayFormatted = data.today.toLocaleString();
|
||
countElement.textContent = `${allTimeFormatted} (Today: ${todayFormatted})`;
|
||
}
|
||
}
|
||
|
||
async function pollServerHealth() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/server-health`);
|
||
const data = await response.json();
|
||
updateServerStatusDisplay(data);
|
||
} catch (e) {
|
||
console.error('Server health fetch failed:', e);
|
||
updateServerStatusDisplay({ status: 'error' });
|
||
}
|
||
}
|
||
|
||
function updateServerStatusDisplay(data) {
|
||
const statusDot = document.getElementById('statusDot');
|
||
const statusText = document.getElementById('statusText');
|
||
const playerCount = document.getElementById('playerCount');
|
||
const latencyMs = document.getElementById('latencyMs');
|
||
const uptime = document.getElementById('uptime');
|
||
const lastRestart = document.getElementById('lastRestart');
|
||
|
||
if (!statusDot || !statusText) return;
|
||
|
||
// Update status indicator
|
||
const status = data.status || 'unknown';
|
||
statusDot.className = `status-dot status-${status}`;
|
||
statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||
|
||
// Update player count
|
||
if (playerCount) {
|
||
playerCount.textContent = data.player_count !== null && data.player_count !== undefined ? data.player_count : '-';
|
||
}
|
||
|
||
// Update latency
|
||
if (latencyMs) {
|
||
latencyMs.textContent = data.latency_ms ? Math.round(data.latency_ms) : '-';
|
||
}
|
||
|
||
// Update uptime
|
||
if (uptime) {
|
||
uptime.textContent = data.uptime || '-';
|
||
}
|
||
|
||
// Update last restart with Stockholm timezone (24h format, no year)
|
||
if (lastRestart) {
|
||
if (data.last_restart) {
|
||
const restartDate = new Date(data.last_restart);
|
||
const formattedDate = restartDate.toLocaleString('sv-SE', {
|
||
timeZone: 'Europe/Stockholm',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false
|
||
});
|
||
lastRestart.textContent = formattedDate;
|
||
} else {
|
||
lastRestart.textContent = 'Unknown';
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleServerStatusUpdate(msg) {
|
||
// Handle real-time server status updates via WebSocket
|
||
if (msg.status === 'up' && msg.message) {
|
||
// Show notification for server coming back online
|
||
console.log(`Server Status: ${msg.message}`);
|
||
}
|
||
|
||
// Trigger an immediate server health poll to refresh the display
|
||
pollServerHealth();
|
||
}
|
||
|
||
function startPolling() {
|
||
if (pollID !== null) return;
|
||
pollLive();
|
||
pollTotalRares(); // Initial fetch
|
||
pollServerHealth(); // Initial server health check
|
||
pollID = setInterval(pollLive, POLL_MS);
|
||
// Poll total rares every 5 minutes (300,000 ms)
|
||
setInterval(pollTotalRares, 300000);
|
||
// Poll server health every 30 seconds (30,000 ms)
|
||
setInterval(pollServerHealth, 30000);
|
||
}
|
||
|
||
img.onload = () => {
|
||
imgW = img.naturalWidth;
|
||
imgH = img.naturalHeight;
|
||
// size the SVG trails container to match the map dimensions
|
||
if (trailsContainer) {
|
||
trailsContainer.setAttribute('viewBox', `0 0 ${imgW} ${imgH}`);
|
||
trailsContainer.setAttribute('width', `${imgW}`);
|
||
trailsContainer.setAttribute('height', `${imgH}`);
|
||
}
|
||
fitToWindow();
|
||
startPolling();
|
||
initWebSocket();
|
||
initHeatMap();
|
||
};
|
||
|
||
// Ensure server health polling starts regardless of image loading
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Start server health polling immediately on DOM ready
|
||
pollServerHealth();
|
||
});
|
||
|
||
/* ---------- rendering sorted list & dots ------------------------ */
|
||
/**
|
||
* Filter and sort the currentPlayers, then render them.
|
||
*/
|
||
function renderList() {
|
||
// Filter by name prefix
|
||
const filtered = currentPlayers.filter(p =>
|
||
p.character_name.toLowerCase().startsWith(currentFilter)
|
||
);
|
||
// Sort filtered list
|
||
const sorted = filtered.slice().sort(currentSort.comparator);
|
||
render(sorted);
|
||
}
|
||
|
||
function render(players) {
|
||
dots.innerHTML = '';
|
||
list.innerHTML = '';
|
||
|
||
// Update header with active player count
|
||
const header = document.getElementById('activePlayersHeader');
|
||
if (header) {
|
||
header.textContent = `Active Mosswart Enjoyers (${players.length})`;
|
||
}
|
||
|
||
// Calculate and update server KPH
|
||
const totalKPH = players.reduce((sum, p) => sum + (p.kills_per_hour || 0), 0);
|
||
const kphElement = document.getElementById('serverKphCount');
|
||
if (kphElement) {
|
||
// Format with commas and one decimal place for EPIC display
|
||
const formattedKPH = totalKPH.toLocaleString('en-US', {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1
|
||
});
|
||
kphElement.textContent = formattedKPH;
|
||
|
||
// Add extra epic effect for high KPH
|
||
const container = document.getElementById('serverKphCounter');
|
||
if (container) {
|
||
if (totalKPH > 5000) {
|
||
container.classList.add('ultra-epic');
|
||
} else {
|
||
container.classList.remove('ultra-epic');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Calculate and update total kills
|
||
const totalKills = players.reduce((sum, p) => sum + (p.total_kills || 0), 0);
|
||
const killsElement = document.getElementById('totalKillsCount');
|
||
if (killsElement) {
|
||
// Format with commas for readability
|
||
const formattedKills = totalKills.toLocaleString();
|
||
killsElement.textContent = formattedKills;
|
||
}
|
||
|
||
players.forEach(p => {
|
||
const { x, y } = worldToPx(p.ew, p.ns);
|
||
|
||
// dot
|
||
const dot = document.createElement('div');
|
||
dot.className = 'dot';
|
||
dot.style.left = `${x}px`;
|
||
dot.style.top = `${y}px`;
|
||
dot.style.background = getColorFor(p.character_name);
|
||
|
||
|
||
|
||
|
||
// custom tooltip
|
||
dot.addEventListener('mouseenter', e => showTooltip(e, p));
|
||
dot.addEventListener('mousemove', e => showTooltip(e, p));
|
||
dot.addEventListener('mouseleave', hideTooltip);
|
||
|
||
// click to select/zoom
|
||
dot.addEventListener('click', () => selectPlayer(p, x, y));
|
||
|
||
if (p.character_name === selected) dot.classList.add('highlight');
|
||
dots.appendChild(dot);
|
||
//sidebar
|
||
const li = document.createElement('li');
|
||
const color = getColorFor(p.character_name);
|
||
li.style.borderLeftColor = color;
|
||
li.className = 'player-item';
|
||
// Calculate KPR (Kills Per Rare)
|
||
const totalKills = p.total_kills || 0;
|
||
const totalRares = p.total_rares || 0;
|
||
const kpr = totalRares > 0 ? Math.round(totalKills / totalRares) : '∞';
|
||
|
||
li.innerHTML = `
|
||
<span class="player-name">${p.character_name}${createVitaeIndicator(p.character_name)} <span class="coordinates-inline">${loc(p.ns, p.ew)}</span></span>
|
||
${createVitalsHTML(p.character_name)}
|
||
<span class="stat kills">${p.kills}</span>
|
||
<span class="stat total-kills">${p.total_kills || 0}</span>
|
||
<span class="stat kph">${p.kills_per_hour}</span>
|
||
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
|
||
<span class="stat kpr">${kpr}</span>
|
||
<span class="stat meta">${p.vt_state}</span>
|
||
<span class="stat onlinetime">${p.onlinetime}</span>
|
||
<span class="stat deaths">${p.deaths}/${p.total_deaths || 0}</span>
|
||
<span class="stat tapers">${p.prismatic_taper_count || 0}</span>
|
||
`;
|
||
|
||
// Color the metastate pill according to its value
|
||
const metaSpan = li.querySelector('.stat.meta');
|
||
if (metaSpan) {
|
||
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
||
const state = (p.vt_state || '').toString().toLowerCase();
|
||
if (goodStates.includes(state)) {
|
||
metaSpan.classList.add('green');
|
||
} else {
|
||
metaSpan.classList.add('red');
|
||
}
|
||
}
|
||
|
||
li.addEventListener('click', () => selectPlayer(p, x, y));
|
||
if (p.character_name === selected) li.classList.add('selected');
|
||
// Chat button
|
||
const chatBtn = document.createElement('button');
|
||
chatBtn.className = 'chat-btn';
|
||
chatBtn.textContent = 'Chat';
|
||
chatBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showChatWindow(p.character_name);
|
||
});
|
||
li.appendChild(chatBtn);
|
||
// Stats button
|
||
const statsBtn = document.createElement('button');
|
||
statsBtn.className = 'stats-btn';
|
||
statsBtn.textContent = 'Stats';
|
||
statsBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showStatsWindow(p.character_name);
|
||
});
|
||
li.appendChild(statsBtn);
|
||
// Inventory button
|
||
const inventoryBtn = document.createElement('button');
|
||
inventoryBtn.className = 'inventory-btn';
|
||
inventoryBtn.textContent = 'Inventory';
|
||
inventoryBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showInventoryWindow(p.character_name);
|
||
});
|
||
li.appendChild(inventoryBtn);
|
||
list.appendChild(li);
|
||
});
|
||
}
|
||
|
||
/* ---------- rendering trails ------------------------------- */
|
||
function renderTrails(trailData) {
|
||
trailsContainer.innerHTML = '';
|
||
const byChar = trailData.reduce((acc, pt) => {
|
||
(acc[pt.character_name] = acc[pt.character_name] || []).push(pt);
|
||
return acc;
|
||
}, {});
|
||
for (const [name, pts] of Object.entries(byChar)) {
|
||
if (pts.length < 2) continue;
|
||
const points = pts.map(pt => {
|
||
const { x, y } = worldToPx(pt.ew, pt.ns);
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
||
poly.setAttribute('points', points);
|
||
// Use the same color as the player dot for consistency
|
||
poly.setAttribute('stroke', getColorFor(name));
|
||
poly.setAttribute('fill', 'none');
|
||
poly.setAttribute('class', 'trail-path');
|
||
trailsContainer.appendChild(poly);
|
||
}
|
||
}
|
||
/* ---------- selection centering, focus zoom & blink ------------ */
|
||
function selectPlayer(p, x, y) {
|
||
selected = p.character_name;
|
||
// set focus zoom
|
||
scale = Math.min(MAX_Z, Math.max(minScale, FOCUS_ZOOM));
|
||
// center on the player
|
||
const r = wrap.getBoundingClientRect();
|
||
offX = r.width / 2 - x * scale;
|
||
offY = r.height / 2 - y * scale;
|
||
updateView();
|
||
renderList(); // keep sorted + highlight
|
||
}
|
||
|
||
/*
|
||
* ---------- Chat & Command WebSocket Handlers ------------------
|
||
* Maintains a persistent WebSocket connection to the /ws/live endpoint
|
||
* for receiving chat messages and sending user commands to plugin clients.
|
||
* Reconnects automatically on close and logs errors.
|
||
*/
|
||
// Initialize WebSocket for chat and command streams
|
||
function initWebSocket() {
|
||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${location.host}${API_BASE}/ws/live`;
|
||
socket = new WebSocket(wsUrl);
|
||
socket.addEventListener('message', evt => {
|
||
let msg;
|
||
try { msg = JSON.parse(evt.data); } catch { return; }
|
||
if (msg.type === 'chat') {
|
||
appendChatMessage(msg);
|
||
} else if (msg.type === 'vitals') {
|
||
updateVitalsDisplay(msg);
|
||
} else if (msg.type === 'rare') {
|
||
triggerEpicRareNotification(msg.character_name, msg.name);
|
||
} else if (msg.type === 'server_status') {
|
||
handleServerStatusUpdate(msg);
|
||
}
|
||
});
|
||
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
||
socket.addEventListener('error', e => console.error('WebSocket error:', e));
|
||
}
|
||
|
||
// Display or create a chat window for a character
|
||
function showChatWindow(name) {
|
||
if (chatWindows[name]) {
|
||
const existing = chatWindows[name];
|
||
// Toggle: close if already visible, open if hidden
|
||
if (existing.style.display === 'flex') {
|
||
existing.style.display = 'none';
|
||
} else {
|
||
existing.style.display = 'flex';
|
||
// Bring to front when opening
|
||
if (!window.__chatZ) window.__chatZ = 10000;
|
||
window.__chatZ += 1;
|
||
existing.style.zIndex = window.__chatZ;
|
||
}
|
||
return;
|
||
}
|
||
const win = document.createElement('div');
|
||
win.className = 'chat-window';
|
||
win.dataset.character = name;
|
||
// Header
|
||
const header = document.createElement('div');
|
||
header.className = 'chat-header';
|
||
const title = document.createElement('span');
|
||
title.textContent = `Chat: ${name}`;
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'chat-close-btn';
|
||
closeBtn.textContent = '×';
|
||
closeBtn.addEventListener('click', () => { win.style.display = 'none'; });
|
||
header.appendChild(title);
|
||
header.appendChild(closeBtn);
|
||
win.appendChild(header);
|
||
// Messages container
|
||
const msgs = document.createElement('div');
|
||
msgs.className = 'chat-messages';
|
||
win.appendChild(msgs);
|
||
// Input form
|
||
const form = document.createElement('form');
|
||
form.className = 'chat-form';
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'chat-input';
|
||
input.placeholder = 'Enter chat...';
|
||
form.appendChild(input);
|
||
form.addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
const text = input.value.trim();
|
||
if (!text) return;
|
||
// Send command envelope: player_name and command only
|
||
socket.send(JSON.stringify({ player_name: name, command: text }));
|
||
input.value = '';
|
||
});
|
||
win.appendChild(form);
|
||
document.body.appendChild(win);
|
||
chatWindows[name] = win;
|
||
|
||
// Enable dragging using the global drag system
|
||
makeDraggable(win, header);
|
||
}
|
||
|
||
// Append a chat message to the correct window
|
||
/**
|
||
* Append a chat message to the correct window, optionally coloring the text.
|
||
* msg: { type: 'chat', character_name, text, color? }
|
||
*/
|
||
function appendChatMessage(msg) {
|
||
const { character_name: name, text, color } = msg;
|
||
const win = chatWindows[name];
|
||
if (!win) return;
|
||
const msgs = win.querySelector('.chat-messages');
|
||
const p = document.createElement('div');
|
||
if (color !== undefined) {
|
||
let c = color;
|
||
if (typeof c === 'number') {
|
||
// map numeric chat code to configured color, or fallback to raw hex
|
||
if (CHAT_COLOR_MAP.hasOwnProperty(c)) {
|
||
c = CHAT_COLOR_MAP[c];
|
||
} else {
|
||
c = '#' + c.toString(16).padStart(6, '0');
|
||
}
|
||
}
|
||
p.style.color = c;
|
||
}
|
||
p.textContent = text;
|
||
msgs.appendChild(p);
|
||
// Enforce max number of lines in scrollback
|
||
while (msgs.children.length > MAX_CHAT_LINES) {
|
||
msgs.removeChild(msgs.firstChild);
|
||
}
|
||
// Scroll to bottom
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
|
||
/* ---------- pan & zoom handlers -------------------------------- */
|
||
wrap.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
if (!imgW) return;
|
||
|
||
const r = wrap.getBoundingClientRect();
|
||
const mx = (e.clientX - r.left - offX) / scale;
|
||
const my = (e.clientY - r.top - offY) / scale;
|
||
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
||
let ns = scale * factor;
|
||
ns = Math.max(minScale, Math.min(MAX_Z, ns));
|
||
|
||
offX -= mx * (ns - scale);
|
||
offY -= my * (ns - scale);
|
||
scale = ns;
|
||
updateView();
|
||
}, { passive: false });
|
||
|
||
wrap.addEventListener('mousedown', e => {
|
||
dragging = true; sx = e.clientX; sy = e.clientY;
|
||
wrap.classList.add('dragging');
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
offX += e.clientX - sx; offY += e.clientY - sy;
|
||
sx = e.clientX; sy = e.clientY;
|
||
updateView();
|
||
});
|
||
window.addEventListener('mouseup', () => {
|
||
dragging = false; wrap.classList.remove('dragging');
|
||
});
|
||
|
||
wrap.addEventListener('touchstart', e => {
|
||
if (e.touches.length !== 1) return;
|
||
dragging = true;
|
||
sx = e.touches[0].clientX; sy = e.touches[0].clientY;
|
||
});
|
||
wrap.addEventListener('touchmove', e => {
|
||
if (!dragging || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
offX += t.clientX - sx; offY += t.clientY - sy;
|
||
sx = t.clientX; sy = t.clientY;
|
||
updateView();
|
||
});
|
||
wrap.addEventListener('touchend', () => {
|
||
dragging = false;
|
||
});
|
||
|
||
/* ---------- coordinate display on hover ---------------------------- */
|
||
wrap.addEventListener('mousemove', e => {
|
||
if (!imgW) return;
|
||
|
||
const r = wrap.getBoundingClientRect();
|
||
const x = e.clientX - r.left;
|
||
const y = e.clientY - r.top;
|
||
|
||
const { ew, ns } = pxToWorld(x, y);
|
||
|
||
// Display coordinates using the same format as the existing loc function
|
||
coordinates.textContent = loc(ns, ew);
|
||
coordinates.style.left = `${x + 10}px`;
|
||
coordinates.style.top = `${y + 10}px`;
|
||
coordinates.style.display = 'block';
|
||
});
|
||
|
||
wrap.addEventListener('mouseleave', () => {
|
||
coordinates.style.display = 'none';
|
||
});
|
||
|
||
/* ---------- vitals display functions ----------------------------- */
|
||
// Store vitals data per character
|
||
const characterVitals = {};
|
||
|
||
function updateVitalsDisplay(vitalsMsg) {
|
||
// Store the vitals data for this character
|
||
characterVitals[vitalsMsg.character_name] = {
|
||
health_percentage: vitalsMsg.health_percentage,
|
||
stamina_percentage: vitalsMsg.stamina_percentage,
|
||
mana_percentage: vitalsMsg.mana_percentage,
|
||
vitae: vitalsMsg.vitae
|
||
};
|
||
|
||
// Re-render the player list to update vitals in the UI
|
||
renderList();
|
||
}
|
||
|
||
function createVitalsHTML(characterName) {
|
||
const vitals = characterVitals[characterName];
|
||
if (!vitals) {
|
||
return ''; // No vitals data available
|
||
}
|
||
|
||
return `
|
||
<div class="player-vitals">
|
||
<div class="vital-bar-inline ${getVitalClass(vitals.health_percentage)}">
|
||
<div class="vital-fill health" style="width: ${vitals.health_percentage}%"></div>
|
||
</div>
|
||
<div class="vital-bar-inline ${getVitalClass(vitals.stamina_percentage)}">
|
||
<div class="vital-fill stamina" style="width: ${vitals.stamina_percentage}%"></div>
|
||
</div>
|
||
<div class="vital-bar-inline ${getVitalClass(vitals.mana_percentage)}">
|
||
<div class="vital-fill mana" style="width: ${vitals.mana_percentage}%"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function createVitaeIndicator(characterName) {
|
||
const vitals = characterVitals[characterName];
|
||
if (!vitals || !vitals.vitae || vitals.vitae >= 100) {
|
||
return ''; // No vitae penalty
|
||
}
|
||
|
||
return `<span class="vitae-indicator">⚰️ ${vitals.vitae}%</span>`;
|
||
}
|
||
|
||
function getVitalClass(percentage) {
|
||
if (percentage <= 25) {
|
||
return 'critical-vital';
|
||
} else if (percentage <= 50) {
|
||
return 'low-vital';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/* ---------- epic rare notification system ------------------------ */
|
||
// Track previous rare count to detect increases
|
||
let lastRareCount = 0;
|
||
let notificationQueue = [];
|
||
let isShowingNotification = false;
|
||
|
||
function triggerEpicRareNotification(characterName, rareName) {
|
||
// Add to queue
|
||
notificationQueue.push({ characterName, rareName });
|
||
|
||
// Process queue if not already showing a notification
|
||
if (!isShowingNotification) {
|
||
processNotificationQueue();
|
||
}
|
||
|
||
// Trigger fireworks immediately
|
||
createFireworks();
|
||
|
||
// Highlight the player in the list
|
||
highlightRareFinder(characterName);
|
||
}
|
||
|
||
function processNotificationQueue() {
|
||
if (notificationQueue.length === 0) {
|
||
isShowingNotification = false;
|
||
return;
|
||
}
|
||
|
||
isShowingNotification = true;
|
||
const notification = notificationQueue.shift();
|
||
|
||
// Create notification element
|
||
const container = document.getElementById('rareNotifications');
|
||
const notifEl = document.createElement('div');
|
||
notifEl.className = 'rare-notification';
|
||
notifEl.innerHTML = `
|
||
<div class="rare-notification-title">🎆 LEGENDARY RARE! 🎆</div>
|
||
<div class="rare-notification-mob">${notification.rareName}</div>
|
||
<div class="rare-notification-finder">found by</div>
|
||
<div class="rare-notification-character">⚔️ ${notification.characterName} ⚔️</div>
|
||
`;
|
||
|
||
container.appendChild(notifEl);
|
||
|
||
// Remove notification after 6 seconds and process next
|
||
setTimeout(() => {
|
||
notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
|
||
setTimeout(() => {
|
||
notifEl.remove();
|
||
processNotificationQueue();
|
||
}, 500);
|
||
}, 6000);
|
||
}
|
||
|
||
// Add slide out animation
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes notification-slide-out {
|
||
to {
|
||
transform: translateY(-100px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
function createFireworks() {
|
||
const container = document.getElementById('fireworksContainer');
|
||
const rareCounter = document.getElementById('totalRaresCounter');
|
||
const rect = rareCounter.getBoundingClientRect();
|
||
|
||
// Create 30 particles
|
||
const particleCount = 30;
|
||
const colors = ['particle-gold', 'particle-red', 'particle-orange', 'particle-purple', 'particle-blue'];
|
||
|
||
for (let i = 0; i < particleCount; i++) {
|
||
const particle = document.createElement('div');
|
||
particle.className = `firework-particle ${colors[Math.floor(Math.random() * colors.length)]}`;
|
||
|
||
// Start position at rare counter
|
||
particle.style.left = `${rect.left + rect.width / 2}px`;
|
||
particle.style.top = `${rect.top + rect.height / 2}px`;
|
||
|
||
// Random explosion direction
|
||
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
|
||
const velocity = 100 + Math.random() * 200;
|
||
const dx = Math.cos(angle) * velocity;
|
||
const dy = Math.sin(angle) * velocity - 50; // Slight upward bias
|
||
|
||
// Create custom animation for this particle
|
||
const animName = `particle-${Date.now()}-${i}`;
|
||
const keyframes = `
|
||
@keyframes ${animName} {
|
||
0% {
|
||
transform: translate(0, 0) scale(1);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: translate(${dx}px, ${dy + 200}px) scale(0);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`;
|
||
|
||
const styleEl = document.createElement('style');
|
||
styleEl.textContent = keyframes;
|
||
document.head.appendChild(styleEl);
|
||
|
||
particle.style.animation = `${animName} 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
|
||
|
||
container.appendChild(particle);
|
||
|
||
// Clean up particle and animation after completion
|
||
setTimeout(() => {
|
||
particle.remove();
|
||
styleEl.remove();
|
||
}, 2000);
|
||
}
|
||
}
|
||
|
||
function highlightRareFinder(characterName) {
|
||
// Find the player in the list
|
||
const playerItems = document.querySelectorAll('#playerList li');
|
||
playerItems.forEach(item => {
|
||
const nameSpan = item.querySelector('.player-name');
|
||
if (nameSpan && nameSpan.textContent.includes(characterName)) {
|
||
item.classList.add('rare-finder-glow');
|
||
// Remove glow after 5 seconds
|
||
setTimeout(() => {
|
||
item.classList.remove('rare-finder-glow');
|
||
}, 5000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Update total rares display to trigger fireworks on increase
|
||
const originalUpdateTotalRaresDisplay = updateTotalRaresDisplay;
|
||
updateTotalRaresDisplay = function(data) {
|
||
originalUpdateTotalRaresDisplay(data);
|
||
|
||
// Check if total increased
|
||
const newTotal = data.all_time || 0;
|
||
if (newTotal > lastRareCount && lastRareCount > 0) {
|
||
// Don't trigger on initial load
|
||
createFireworks();
|
||
|
||
// Check for milestones when count increases
|
||
if (newTotal > 0 && newTotal % 100 === 0) {
|
||
triggerMilestoneCelebration(newTotal);
|
||
}
|
||
}
|
||
lastRareCount = newTotal;
|
||
}
|
||
|
||
function triggerMilestoneCelebration(rareNumber) {
|
||
console.log(`🏆 MILESTONE: Rare #${rareNumber}! 🏆`);
|
||
|
||
// Create full-screen milestone overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'milestone-overlay';
|
||
|
||
overlay.innerHTML = `
|
||
<div class="milestone-content">
|
||
<div class="milestone-number">#${rareNumber}</div>
|
||
<div class="milestone-text">🏆 EPIC MILESTONE! 🏆</div>
|
||
<div class="milestone-subtitle">Server Achievement Unlocked</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
// Add screen shake effect
|
||
document.body.classList.add('screen-shake');
|
||
|
||
// Create massive firework explosion
|
||
createMilestoneFireworks();
|
||
|
||
// Remove milestone overlay after 5 seconds
|
||
setTimeout(() => {
|
||
overlay.style.animation = 'milestone-fade-in 0.5s ease-out reverse';
|
||
document.body.classList.remove('screen-shake');
|
||
setTimeout(() => {
|
||
overlay.remove();
|
||
}, 500);
|
||
}, 5000);
|
||
}
|
||
|
||
function createMilestoneFireworks() {
|
||
const container = document.getElementById('fireworksContainer');
|
||
|
||
// Create multiple bursts across the screen
|
||
const burstCount = 5;
|
||
const particlesPerBurst = 50;
|
||
const colors = ['#ffd700', '#ff6600', '#ff0044', '#cc00ff', '#00ccff', '#00ff00'];
|
||
|
||
for (let burst = 0; burst < burstCount; burst++) {
|
||
setTimeout(() => {
|
||
// Random position for each burst
|
||
const x = Math.random() * window.innerWidth;
|
||
const y = Math.random() * (window.innerHeight * 0.7) + (window.innerHeight * 0.15);
|
||
|
||
for (let i = 0; i < particlesPerBurst; i++) {
|
||
const particle = document.createElement('div');
|
||
particle.className = 'milestone-particle';
|
||
particle.style.background = colors[Math.floor(Math.random() * colors.length)];
|
||
particle.style.boxShadow = `0 0 12px ${particle.style.background}`;
|
||
|
||
// Start position
|
||
particle.style.left = `${x}px`;
|
||
particle.style.top = `${y}px`;
|
||
|
||
// Random explosion direction
|
||
const angle = (Math.PI * 2 * i) / particlesPerBurst + (Math.random() - 0.5) * 0.8;
|
||
const velocity = 200 + Math.random() * 300;
|
||
const dx = Math.cos(angle) * velocity;
|
||
const dy = Math.sin(angle) * velocity - 100; // Upward bias
|
||
|
||
// Create custom animation
|
||
const animName = `milestone-particle-${Date.now()}-${burst}-${i}`;
|
||
const keyframes = `
|
||
@keyframes ${animName} {
|
||
0% {
|
||
transform: translate(0, 0) scale(1);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: translate(${dx}px, ${dy + 400}px) scale(0);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`;
|
||
|
||
const styleEl = document.createElement('style');
|
||
styleEl.textContent = keyframes;
|
||
document.head.appendChild(styleEl);
|
||
|
||
particle.style.animation = `${animName} 3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards`;
|
||
|
||
container.appendChild(particle);
|
||
|
||
// Clean up
|
||
setTimeout(() => {
|
||
particle.remove();
|
||
styleEl.remove();
|
||
}, 3000);
|
||
}
|
||
}, burst * 200); // Stagger bursts
|
||
}
|
||
}
|
||
|
||
/* ==================== INVENTORY SEARCH FUNCTIONALITY ==================== */
|
||
|
||
/**
|
||
* Opens the dedicated inventory search page in a new browser tab.
|
||
*/
|
||
function openInventorySearch() {
|
||
// Open the dedicated inventory search page in a new tab
|
||
window.open('/inventory.html', '_blank');
|
||
}
|
||
|
||
/**
|
||
* Opens the Suitbuilder interface in a new browser tab.
|
||
*/
|
||
function openSuitbuilder() {
|
||
// Open the Suitbuilder page in a new tab
|
||
window.open('/suitbuilder.html', '_blank');
|
||
}
|
||
|
||
/**
|
||
* Opens the Player Debug interface in a new browser tab.
|
||
*/
|
||
function openPlayerDebug() {
|
||
// Open the Player Debug page in a new tab
|
||
window.open('/debug.html', '_blank');
|
||
}
|
||
|
||
|