- Fix remaining f-string SQL injection in process_inventory (same pattern as single-item endpoints: parameterized ANY(:ids) queries) - Add null guard for item_id in backend delta remove handler - Add response status logging for inventory service HTTP calls - Fix frontend ID fallback consistency in updateInventoryLive - Replace debug print() with logger.debug() - Add comment for Decal Slot_Decal magic number Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2853 lines
101 KiB
JavaScript
2853 lines
101 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
|
|
*/
|
|
/* ---------- Debug configuration ---------------------------------- */
|
|
const DEBUG = false;
|
|
function debugLog(...args) { if (DEBUG) console.log(...args); }
|
|
|
|
function handleError(context, error, showUI = false) {
|
|
console.error(`[${context}]`, error);
|
|
if (showUI) {
|
|
const msg = document.createElement('div');
|
|
msg.className = 'error-toast';
|
|
msg.textContent = `${context}: ${error.message || 'Unknown error'}`;
|
|
document.body.appendChild(msg);
|
|
setTimeout(() => msg.remove(), GLOW_DURATION_MS);
|
|
}
|
|
}
|
|
|
|
/* ---------- 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');
|
|
|
|
/* ---------- Element Pooling System for Performance ------------- */
|
|
// Pools for reusing DOM elements to eliminate recreation overhead
|
|
const elementPools = {
|
|
dots: [],
|
|
listItems: [],
|
|
activeDots: new Set(),
|
|
activeListItems: new Set()
|
|
};
|
|
|
|
// Performance tracking
|
|
let performanceStats = {
|
|
// Lifetime totals
|
|
dotsCreated: 0,
|
|
dotsReused: 0,
|
|
listItemsCreated: 0,
|
|
listItemsReused: 0,
|
|
// Per-render stats (reset each render)
|
|
renderDotsCreated: 0,
|
|
renderDotsReused: 0,
|
|
renderListItemsCreated: 0,
|
|
renderListItemsReused: 0,
|
|
lastRenderTime: 0,
|
|
renderCount: 0
|
|
};
|
|
|
|
function createNewDot() {
|
|
const dot = document.createElement('div');
|
|
dot.className = 'dot';
|
|
performanceStats.dotsCreated++;
|
|
performanceStats.renderDotsCreated++;
|
|
|
|
// Add event listeners once when creating
|
|
dot.addEventListener('mouseenter', e => showTooltip(e, dot.playerData));
|
|
dot.addEventListener('mousemove', e => showTooltip(e, dot.playerData));
|
|
dot.addEventListener('mouseleave', hideTooltip);
|
|
dot.addEventListener('click', () => {
|
|
if (dot.playerData) {
|
|
const { x, y } = worldToPx(dot.playerData.ew, dot.playerData.ns);
|
|
selectPlayer(dot.playerData, x, y);
|
|
}
|
|
});
|
|
|
|
return dot;
|
|
}
|
|
|
|
function createNewListItem() {
|
|
const li = document.createElement('li');
|
|
li.className = 'player-item';
|
|
performanceStats.listItemsCreated++;
|
|
performanceStats.renderListItemsCreated++;
|
|
|
|
// Create the grid content container
|
|
const gridContent = document.createElement('div');
|
|
gridContent.className = 'grid-content';
|
|
li.appendChild(gridContent);
|
|
|
|
// Create buttons once and keep them (no individual event listeners needed)
|
|
const buttonsContainer = document.createElement('div');
|
|
buttonsContainer.className = 'buttons-container';
|
|
|
|
const chatBtn = document.createElement('button');
|
|
chatBtn.className = 'chat-btn';
|
|
chatBtn.textContent = 'Chat';
|
|
chatBtn.addEventListener('click', (e) => {
|
|
debugLog('🔥 CHAT BUTTON CLICKED!', e.target, e.currentTarget);
|
|
e.stopPropagation();
|
|
// Try button's own playerData first, fallback to DOM traversal
|
|
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
|
|
debugLog('🔥 Player data found:', playerData);
|
|
if (playerData) {
|
|
debugLog('🔥 Opening chat for:', playerData.character_name);
|
|
showChatWindow(playerData.character_name);
|
|
} else {
|
|
debugLog('🔥 No player data found!');
|
|
}
|
|
});
|
|
|
|
const statsBtn = document.createElement('button');
|
|
statsBtn.className = 'stats-btn';
|
|
statsBtn.textContent = 'Stats';
|
|
statsBtn.addEventListener('click', (e) => {
|
|
debugLog('📊 STATS BUTTON CLICKED!', e.target, e.currentTarget);
|
|
e.stopPropagation();
|
|
// Try button's own playerData first, fallback to DOM traversal
|
|
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
|
|
debugLog('📊 Player data found:', playerData);
|
|
if (playerData) {
|
|
debugLog('📊 Opening stats for:', playerData.character_name);
|
|
showStatsWindow(playerData.character_name);
|
|
} else {
|
|
debugLog('📊 No player data found!');
|
|
}
|
|
});
|
|
|
|
const inventoryBtn = document.createElement('button');
|
|
inventoryBtn.className = 'inventory-btn';
|
|
inventoryBtn.textContent = 'Inventory';
|
|
inventoryBtn.addEventListener('click', (e) => {
|
|
debugLog('🎒 INVENTORY BUTTON CLICKED!', e.target, e.currentTarget);
|
|
e.stopPropagation();
|
|
// Try button's own playerData first, fallback to DOM traversal
|
|
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
|
|
debugLog('🎒 Player data found:', playerData);
|
|
if (playerData) {
|
|
debugLog('🎒 Opening inventory for:', playerData.character_name);
|
|
showInventoryWindow(playerData.character_name);
|
|
} else {
|
|
debugLog('🎒 No player data found!');
|
|
}
|
|
});
|
|
|
|
const charBtn = document.createElement('button');
|
|
charBtn.className = 'char-btn';
|
|
charBtn.textContent = 'Char';
|
|
charBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
|
|
if (playerData) {
|
|
showCharacterWindow(playerData.character_name);
|
|
}
|
|
});
|
|
|
|
buttonsContainer.appendChild(chatBtn);
|
|
buttonsContainer.appendChild(statsBtn);
|
|
buttonsContainer.appendChild(inventoryBtn);
|
|
buttonsContainer.appendChild(charBtn);
|
|
li.appendChild(buttonsContainer);
|
|
|
|
// Store references for easy access
|
|
li.gridContent = gridContent;
|
|
li.chatBtn = chatBtn;
|
|
li.statsBtn = statsBtn;
|
|
li.inventoryBtn = inventoryBtn;
|
|
li.charBtn = charBtn;
|
|
|
|
return li;
|
|
}
|
|
|
|
function returnToPool() {
|
|
// Return unused dots to pool
|
|
elementPools.activeDots.forEach(dot => {
|
|
if (!dot.parentNode) {
|
|
elementPools.dots.push(dot);
|
|
elementPools.activeDots.delete(dot);
|
|
}
|
|
});
|
|
|
|
// Return unused list items to pool
|
|
elementPools.activeListItems.forEach(li => {
|
|
if (!li.parentNode) {
|
|
elementPools.listItems.push(li);
|
|
elementPools.activeListItems.delete(li);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- Event Delegation System ---------------------------- */
|
|
// Single event delegation handler for all player list interactions
|
|
function setupEventDelegation() {
|
|
list.addEventListener('click', e => {
|
|
const li = e.target.closest('li.player-item');
|
|
if (!li || !li.playerData) return;
|
|
|
|
const player = li.playerData;
|
|
const { x, y } = worldToPx(player.ew, player.ns);
|
|
|
|
// Handle player selection (clicking anywhere else on the item, not on buttons)
|
|
// Button clicks are now handled by direct event listeners
|
|
if (!e.target.closest('button')) {
|
|
selectPlayer(player, x, y);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize event delegation when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', setupEventDelegation);
|
|
|
|
// 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;
|
|
const POLL_RARES_MS = 300000; // 5 minutes
|
|
const POLL_KILLS_MS = 300000; // 5 minutes
|
|
const POLL_HEALTH_MS = 30000; // 30 seconds
|
|
const NOTIFICATION_DURATION_MS = 6000; // Rare notification display time
|
|
const GLOW_DURATION_MS = 5000; // Player glow after rare find
|
|
const MAX_HEATMAP_POINTS = 50000;
|
|
const HEATMAP_HOURS = 24;
|
|
// 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
|
|
|
|
/* ---------- Portal Map Globals ---------- */
|
|
let portalEnabled = false;
|
|
let portalData = null;
|
|
let portalContainer = null;
|
|
|
|
/**
|
|
* ---------- 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 = "";
|
|
const pollIntervals = [];
|
|
|
|
/* ---------- 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=${HEATMAP_HOURS}&limit=${MAX_HEATMAP_POINTS}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Heat map API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
heatmapData = data.spawn_points; // [{ew, ns, intensity}]
|
|
debugLog(`Loaded ${heatmapData.length} heat map points from last ${data.hours_window} hours`);
|
|
renderHeatmap();
|
|
} catch (err) {
|
|
handleError('Heatmap', 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);
|
|
}
|
|
}
|
|
|
|
/* ---------- Portal Map Functions ---------- */
|
|
function initPortalMap() {
|
|
portalContainer = document.getElementById('portals');
|
|
if (!portalContainer) {
|
|
console.error('Portal container not found');
|
|
return;
|
|
}
|
|
|
|
const toggle = document.getElementById('portalToggle');
|
|
if (toggle) {
|
|
toggle.addEventListener('change', e => {
|
|
portalEnabled = e.target.checked;
|
|
if (portalEnabled) {
|
|
fetchPortalData();
|
|
} else {
|
|
clearPortals();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function fetchPortalData() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/portals`);
|
|
if (!response.ok) {
|
|
throw new Error(`Portal API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
portalData = data.portals; // [{portal_name, coordinates: {ns, ew, z}, discovered_by, discovered_at}]
|
|
debugLog(`Loaded ${portalData.length} portals from last hour`);
|
|
renderPortals();
|
|
} catch (err) {
|
|
handleError('Portals', err);
|
|
}
|
|
}
|
|
|
|
function parseCoordinate(coord) {
|
|
// Handle both formats:
|
|
// String format: "42.3N", "15.7S", "33.7E", "28.2W"
|
|
// Numeric format: "-96.9330958" (already signed)
|
|
|
|
// Check if it's already a number
|
|
if (typeof coord === 'number') {
|
|
return coord;
|
|
}
|
|
|
|
// Check if it's a numeric string
|
|
const numericValue = parseFloat(coord);
|
|
if (!isNaN(numericValue) && coord.match(/^-?\d+\.?\d*$/)) {
|
|
return numericValue;
|
|
}
|
|
|
|
// Parse string format like "42.3N"
|
|
const match = coord.match(/^([0-9.]+)([NSEW])$/);
|
|
if (!match) return 0;
|
|
|
|
const value = parseFloat(match[1]);
|
|
const direction = match[2];
|
|
|
|
if (direction === 'S' || direction === 'W') {
|
|
return -value;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function renderPortals() {
|
|
if (!portalEnabled || !portalData || !portalContainer || !imgW || !imgH) {
|
|
return;
|
|
}
|
|
|
|
// Clear existing portals
|
|
clearPortals();
|
|
|
|
for (const portal of portalData) {
|
|
// Extract coordinates from new API format
|
|
const ns = portal.coordinates.ns;
|
|
const ew = portal.coordinates.ew;
|
|
|
|
// Convert to pixel coordinates
|
|
const { x, y } = worldToPx(ew, ns);
|
|
|
|
// Create portal icon
|
|
const icon = document.createElement('div');
|
|
icon.className = 'portal-icon';
|
|
icon.style.left = `${x}px`;
|
|
icon.style.top = `${y}px`;
|
|
icon.title = `${portal.portal_name} (discovered by ${portal.discovered_by})`;
|
|
|
|
portalContainer.appendChild(icon);
|
|
}
|
|
|
|
debugLog(`Rendered ${portalData.length} portal icons`);
|
|
}
|
|
|
|
function clearPortals() {
|
|
if (portalContainer) {
|
|
portalContainer.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function debounce(fn, ms) {
|
|
let timeout;
|
|
return (...args) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => fn(...args), ms);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create or show a draggable window. Returns { win, content, isNew }.
|
|
* If window already exists, brings it to front and returns isNew: false.
|
|
*/
|
|
function createWindow(id, title, className, options = {}) {
|
|
const { onClose } = options;
|
|
|
|
// Check if window already exists - bring to front
|
|
const existing = document.getElementById(id);
|
|
if (existing) {
|
|
existing.style.display = 'flex';
|
|
if (!window.__chatZ) window.__chatZ = 10000;
|
|
window.__chatZ += 1;
|
|
existing.style.zIndex = window.__chatZ;
|
|
return { win: existing, content: existing.querySelector('.window-content'), isNew: false };
|
|
}
|
|
|
|
// Create new window
|
|
if (!window.__chatZ) window.__chatZ = 10000;
|
|
window.__chatZ += 1;
|
|
|
|
const win = document.createElement('div');
|
|
win.id = id;
|
|
win.className = className;
|
|
win.style.display = 'flex';
|
|
win.style.zIndex = window.__chatZ;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'chat-header';
|
|
|
|
const titleSpan = document.createElement('span');
|
|
titleSpan.textContent = title;
|
|
header.appendChild(titleSpan);
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.className = 'chat-close-btn';
|
|
closeBtn.textContent = '\u00D7';
|
|
closeBtn.addEventListener('click', () => {
|
|
win.style.display = 'none';
|
|
if (onClose) onClose();
|
|
});
|
|
header.appendChild(closeBtn);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'window-content';
|
|
|
|
win.appendChild(header);
|
|
win.appendChild(content);
|
|
document.body.appendChild(win);
|
|
makeDraggable(win, header);
|
|
|
|
return { win, content, isNew: true };
|
|
}
|
|
|
|
// Show or create a stats window for a character
|
|
function showStatsWindow(name) {
|
|
debugLog('showStatsWindow called for:', name);
|
|
const windowId = `statsWindow-${name}`;
|
|
|
|
const { win, content, isNew } = createWindow(
|
|
windowId, `Stats: ${name}`, 'stats-window'
|
|
);
|
|
|
|
if (!isNew) {
|
|
debugLog('Existing stats window found, showing it');
|
|
return;
|
|
}
|
|
|
|
win.dataset.character = name;
|
|
statsWindows[name] = win;
|
|
|
|
// 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' }
|
|
];
|
|
|
|
// Stats content container (iframes grid)
|
|
const statsContent = document.createElement('div');
|
|
statsContent.className = 'chat-messages';
|
|
statsContent.textContent = 'Loading stats...';
|
|
|
|
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(statsContent, name, range.value);
|
|
});
|
|
controls.appendChild(btn);
|
|
});
|
|
|
|
content.appendChild(controls);
|
|
content.appendChild(statsContent);
|
|
|
|
debugLog('Stats window created for:', name);
|
|
// Load initial stats with default 24h range
|
|
updateStatsTimeRange(statsContent, name, 'now-24h');
|
|
}
|
|
|
|
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
|
|
/**
|
|
* Create a single inventory slot DOM element from item data.
|
|
* Used by both initial inventory load and live delta updates.
|
|
*/
|
|
function createInventorySlot(item) {
|
|
const slot = document.createElement('div');
|
|
slot.className = 'inventory-slot';
|
|
slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0);
|
|
|
|
// Create layered icon container
|
|
const iconContainer = document.createElement('div');
|
|
iconContainer.className = 'item-icon-composite';
|
|
|
|
// Get base icon ID with portal.dat offset
|
|
const iconRaw = item.icon || item.Icon || 0;
|
|
const baseIconId = (iconRaw + 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') {
|
|
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');
|
|
}
|
|
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.IntValues) {
|
|
// Raw delta format from plugin - IntValues directly on item
|
|
if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) {
|
|
overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
|
}
|
|
if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) {
|
|
underlayIconId = (item.IntValues['218103850'] + 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) {
|
|
if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) {
|
|
overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0');
|
|
}
|
|
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 || 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 || item.Name || 'Unknown Item';
|
|
baseImg.onerror = function() { 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 (handle both inventory-service format and raw plugin format)
|
|
const itemName = item.name || item.Name || 'Unknown Item';
|
|
slot.dataset.name = itemName;
|
|
slot.dataset.value = item.value || item.Value || 0;
|
|
slot.dataset.burden = item.burden || item.Burden || 0;
|
|
|
|
// Store enhanced data for tooltips
|
|
if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) {
|
|
const enhancedData = {};
|
|
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'
|
|
];
|
|
possibleProps.forEach(prop => {
|
|
if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) {
|
|
enhancedData[prop] = item[prop];
|
|
}
|
|
});
|
|
slot.dataset.enhancedData = JSON.stringify(enhancedData);
|
|
} else {
|
|
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);
|
|
return slot;
|
|
}
|
|
|
|
/**
|
|
* Handle live inventory delta updates from WebSocket.
|
|
* Updates the inventory grid for a character if their inventory window is open.
|
|
*/
|
|
function updateInventoryLive(delta) {
|
|
const name = delta.character_name;
|
|
const win = inventoryWindows[name];
|
|
if (!win) return; // No inventory window open for this character
|
|
|
|
const grid = win.querySelector('.inventory-grid');
|
|
if (!grid) return;
|
|
|
|
if (delta.action === 'remove') {
|
|
const itemId = delta.item_id || (delta.item && (delta.item.Id || delta.item.id));
|
|
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
|
|
if (existing) existing.remove();
|
|
} else if (delta.action === 'add') {
|
|
const newSlot = createInventorySlot(delta.item);
|
|
grid.appendChild(newSlot);
|
|
} else if (delta.action === 'update') {
|
|
const itemId = delta.item.Id || delta.item.id || delta.item.item_id;
|
|
const existing = grid.querySelector(`[data-item-id="${itemId}"]`);
|
|
if (existing) {
|
|
const newSlot = createInventorySlot(delta.item);
|
|
existing.replaceWith(newSlot);
|
|
} else {
|
|
const newSlot = createInventorySlot(delta.item);
|
|
grid.appendChild(newSlot);
|
|
}
|
|
}
|
|
|
|
// Update item count
|
|
const countEl = win.querySelector('.inventory-count');
|
|
if (countEl) {
|
|
const slotCount = grid.querySelectorAll('.inventory-slot').length;
|
|
countEl.textContent = `${slotCount} items`;
|
|
}
|
|
}
|
|
|
|
function showInventoryWindow(name) {
|
|
debugLog('showInventoryWindow called for:', name);
|
|
const windowId = `inventoryWindow-${name}`;
|
|
|
|
const { win, content, isNew } = createWindow(
|
|
windowId, `Inventory: ${name}`, 'inventory-window'
|
|
);
|
|
|
|
if (!isNew) {
|
|
debugLog('Existing inventory window found, showing it');
|
|
return;
|
|
}
|
|
|
|
win.dataset.character = name;
|
|
inventoryWindows[name] = win;
|
|
|
|
// Loading message
|
|
const loading = document.createElement('div');
|
|
loading.className = 'inventory-loading';
|
|
loading.textContent = 'Loading inventory...';
|
|
content.appendChild(loading);
|
|
|
|
// Inventory content container
|
|
const invContent = document.createElement('div');
|
|
invContent.className = 'inventory-content';
|
|
invContent.style.display = 'none';
|
|
content.appendChild(invContent);
|
|
|
|
// 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';
|
|
invContent.style.display = 'block';
|
|
|
|
// Create inventory grid
|
|
const grid = document.createElement('div');
|
|
grid.className = 'inventory-grid';
|
|
|
|
// Render each item
|
|
data.items.forEach(item => {
|
|
grid.appendChild(createInventorySlot(item));
|
|
});
|
|
|
|
invContent.appendChild(grid);
|
|
|
|
// Add item count
|
|
const count = document.createElement('div');
|
|
count.className = 'inventory-count';
|
|
count.textContent = `${data.item_count} items`;
|
|
invContent.appendChild(count);
|
|
})
|
|
.catch(err => {
|
|
handleError('Inventory', err, true);
|
|
loading.textContent = `Failed to load inventory: ${err.message}`;
|
|
});
|
|
|
|
debugLog('Inventory window created for:', name);
|
|
}
|
|
|
|
// === TreeStats Property ID Mappings ===
|
|
const TS_AUGMENTATIONS = {
|
|
218: "Reinforcement of the Lugians", 219: "Bleeargh's Fortitude", 220: "Oswald's Enhancement",
|
|
221: "Siraluun's Blessing", 222: "Enduring Calm", 223: "Steadfast Will",
|
|
224: "Ciandra's Essence", 225: "Yoshi's Essence", 226: "Jibril's Essence",
|
|
227: "Celdiseth's Essence", 228: "Koga's Essence", 229: "Shadow of the Seventh Mule",
|
|
230: "Might of the Seventh Mule", 231: "Clutch of the Miser", 232: "Enduring Enchantment",
|
|
233: "Critical Protection", 234: "Quick Learner", 235: "Ciandra's Fortune",
|
|
236: "Charmed Smith", 237: "Innate Renewal", 238: "Archmage's Endurance",
|
|
239: "Enhancement of the Blade Turner", 240: "Enhancement of the Arrow Turner",
|
|
241: "Enhancement of the Mace Turner", 242: "Caustic Enhancement", 243: "Fierce Impaler",
|
|
244: "Iron Skin of the Invincible", 245: "Eye of the Remorseless", 246: "Hand of the Remorseless",
|
|
294: "Master of the Steel Circle", 295: "Master of the Focused Eye",
|
|
296: "Master of the Five Fold Path", 297: "Frenzy of the Slayer",
|
|
298: "Iron Skin of the Invincible", 299: "Jack of All Trades",
|
|
300: "Infused Void Magic", 301: "Infused War Magic",
|
|
302: "Infused Life Magic", 309: "Infused Item Magic",
|
|
310: "Infused Creature Magic", 326: "Clutch of the Miser",
|
|
328: "Enduring Enchantment"
|
|
};
|
|
const TS_AURAS = {
|
|
333: "Valor / Destruction", 334: "Protection", 335: "Glory / Retribution",
|
|
336: "Temperance / Hardening", 338: "Aetheric Vision", 339: "Mana Flow",
|
|
340: "Mana Infusion", 342: "Purity", 343: "Craftsman", 344: "Specialization",
|
|
365: "World"
|
|
};
|
|
const TS_RATINGS = {
|
|
370: "Damage", 371: "Damage Resistance", 372: "Critical", 373: "Critical Resistance",
|
|
374: "Critical Damage", 375: "Critical Damage Resistance", 376: "Healing Boost",
|
|
379: "Vitality"
|
|
};
|
|
const TS_SOCIETY = { 287: "Celestial Hand", 288: "Eldrytch Web", 289: "Radiant Blood" };
|
|
const TS_MASTERIES = { 354: "Melee", 355: "Ranged", 362: "Summoning" };
|
|
const TS_MASTERY_NAMES = { 1: "Unarmed", 2: "Swords", 3: "Axes", 4: "Maces", 5: "Spears", 6: "Daggers", 7: "Staves", 8: "Bows", 9: "Crossbows", 10: "Thrown", 11: "Two-Handed", 12: "Void", 13: "War", 14: "Life" };
|
|
const TS_GENERAL = { 181: "Chess Rank", 192: "Fishing Skill", 199: "Total Augmentations", 322: "Aetheria Slots", 390: "Enlightenment" };
|
|
|
|
function _tsSocietyRank(v) {
|
|
if (v >= 1001) return "Master";
|
|
if (v >= 301) return "Lord";
|
|
if (v >= 151) return "Knight";
|
|
if (v >= 31) return "Adept";
|
|
return "Initiate";
|
|
}
|
|
|
|
function _tsSetupTabs(container) {
|
|
const tabs = container.querySelectorAll('.ts-tab');
|
|
const boxes = container.querySelectorAll('.ts-box');
|
|
tabs.forEach((tab, i) => {
|
|
tab.addEventListener('click', () => {
|
|
tabs.forEach(t => { t.classList.remove('active'); t.classList.add('inactive'); });
|
|
boxes.forEach(b => { b.classList.remove('active'); b.classList.add('inactive'); });
|
|
tab.classList.remove('inactive'); tab.classList.add('active');
|
|
if (boxes[i]) { boxes[i].classList.remove('inactive'); boxes[i].classList.add('active'); }
|
|
});
|
|
});
|
|
}
|
|
|
|
function showCharacterWindow(name) {
|
|
debugLog('showCharacterWindow called for:', name);
|
|
const windowId = `characterWindow-${name}`;
|
|
|
|
const { win, content, isNew } = createWindow(
|
|
windowId, `Character: ${name}`, 'character-window'
|
|
);
|
|
|
|
if (!isNew) {
|
|
debugLog('Existing character window found, showing it');
|
|
return;
|
|
}
|
|
|
|
win.dataset.character = name;
|
|
characterWindows[name] = win;
|
|
|
|
const esc = CSS.escape(name);
|
|
content.innerHTML = `
|
|
<div class="ts-character-header" id="charHeader-${esc}">
|
|
<h1>${name} <span class="ts-level"></span></h1>
|
|
<div class="ts-subtitle">Awaiting character data...</div>
|
|
</div>
|
|
<div class="ts-xplum" id="charXpLum-${esc}">
|
|
<div class="ts-left">Total XP: \u2014</div>
|
|
<div class="ts-right">Unassigned XP: \u2014</div>
|
|
<div class="ts-left">Luminance: \u2014</div>
|
|
<div class="ts-right">Deaths: \u2014</div>
|
|
</div>
|
|
<div class="ts-tabrow">
|
|
<div class="ts-tabcontainer" id="charTabLeft-${esc}">
|
|
<div class="ts-tabbar">
|
|
<div class="ts-tab active">Attributes</div>
|
|
<div class="ts-tab inactive">Skills</div>
|
|
<div class="ts-tab inactive">Titles</div>
|
|
</div>
|
|
<div class="ts-box active" id="charAttribs-${esc}">
|
|
<div class="ts-vitals" id="charVitals-${esc}">
|
|
<div class="ts-vital">
|
|
<span class="ts-vital-label">Health</span>
|
|
<div class="ts-vital-bar ts-health-bar"><div class="ts-vital-fill"></div></div>
|
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
|
</div>
|
|
<div class="ts-vital">
|
|
<span class="ts-vital-label">Stamina</span>
|
|
<div class="ts-vital-bar ts-stamina-bar"><div class="ts-vital-fill"></div></div>
|
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
|
</div>
|
|
<div class="ts-vital">
|
|
<span class="ts-vital-label">Mana</span>
|
|
<div class="ts-vital-bar ts-mana-bar"><div class="ts-vital-fill"></div></div>
|
|
<span class="ts-vital-text">\u2014 / \u2014</span>
|
|
</div>
|
|
</div>
|
|
<table class="ts-char" id="charAttribTable-${esc}">
|
|
<tr class="ts-colnames"><td>Attribute</td><td>Creation</td><td>Base</td></tr>
|
|
<tr><td class="ts-headerleft">Strength</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Endurance</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Coordination</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Quickness</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Focus</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Self</td><td class="ts-creation">\u2014</td><td class="ts-headerright">\u2014</td></tr>
|
|
</table>
|
|
<table class="ts-char" id="charVitalsTable-${esc}">
|
|
<tr class="ts-colnames"><td>Vital</td><td>Base</td></tr>
|
|
<tr><td class="ts-headerleft">Health</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Stamina</td><td class="ts-headerright">\u2014</td></tr>
|
|
<tr><td class="ts-headerleft">Mana</td><td class="ts-headerright">\u2014</td></tr>
|
|
</table>
|
|
<table class="ts-char" id="charCredits-${esc}">
|
|
<tr><td class="ts-headerleft">Skill Credits</td><td class="ts-headerright">\u2014</td></tr>
|
|
</table>
|
|
</div>
|
|
<div class="ts-box inactive" id="charSkills-${esc}">
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
<div class="ts-box inactive" id="charTitles-${esc}">
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
</div>
|
|
<div class="ts-tabcontainer" id="charTabRight-${esc}">
|
|
<div class="ts-tabbar">
|
|
<div class="ts-tab active">Augmentations</div>
|
|
<div class="ts-tab inactive">Ratings</div>
|
|
<div class="ts-tab inactive">Other</div>
|
|
</div>
|
|
<div class="ts-box active" id="charAugs-${esc}">
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
<div class="ts-box inactive" id="charRatings-${esc}">
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
<div class="ts-box inactive" id="charOther-${esc}">
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="ts-allegiance-section" id="charAllegiance-${esc}">
|
|
<div class="ts-section-title">Allegiance</div>
|
|
<div class="ts-placeholder">Awaiting data...</div>
|
|
</div>
|
|
`;
|
|
|
|
// Wire up tab switching
|
|
const leftTabs = document.getElementById(`charTabLeft-${esc}`);
|
|
const rightTabs = document.getElementById(`charTabRight-${esc}`);
|
|
if (leftTabs) _tsSetupTabs(leftTabs);
|
|
if (rightTabs) _tsSetupTabs(rightTabs);
|
|
|
|
// Fetch existing data from API
|
|
fetch(`${API_BASE}/character-stats/${encodeURIComponent(name)}`)
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
if (data && !data.error) {
|
|
characterStats[name] = data;
|
|
updateCharacterWindow(name, data);
|
|
}
|
|
})
|
|
.catch(err => handleError('Character stats', err));
|
|
|
|
// If we already have vitals from the live stream, apply them
|
|
if (characterVitals[name]) {
|
|
updateCharacterVitals(name, characterVitals[name]);
|
|
}
|
|
}
|
|
|
|
function updateCharacterWindow(name, data) {
|
|
const esc = CSS.escape(name);
|
|
const fmt = n => n != null ? n.toLocaleString() : '\u2014';
|
|
|
|
// -- Header --
|
|
const header = document.getElementById(`charHeader-${esc}`);
|
|
if (header) {
|
|
const level = data.level || '?';
|
|
const race = data.race || '';
|
|
const gender = data.gender || '';
|
|
const parts = [gender, race].filter(Boolean).join(' ');
|
|
header.querySelector('.ts-subtitle').textContent = parts || 'Awaiting data...';
|
|
const levelSpan = header.querySelector('.ts-level');
|
|
if (levelSpan) levelSpan.textContent = level;
|
|
}
|
|
|
|
// -- XP / Luminance row --
|
|
const xplum = document.getElementById(`charXpLum-${esc}`);
|
|
if (xplum) {
|
|
const divs = xplum.querySelectorAll('div');
|
|
if (divs[0]) divs[0].textContent = `Total XP: ${fmt(data.total_xp)}`;
|
|
if (divs[1]) divs[1].textContent = `Unassigned XP: ${fmt(data.unassigned_xp)}`;
|
|
if (divs[2]) {
|
|
const lum = data.luminance_earned != null && data.luminance_total != null
|
|
? `${fmt(data.luminance_earned)} / ${fmt(data.luminance_total)}`
|
|
: '\u2014';
|
|
divs[2].textContent = `Luminance: ${lum}`;
|
|
}
|
|
if (divs[3]) divs[3].textContent = `Deaths: ${fmt(data.deaths)}`;
|
|
}
|
|
|
|
// -- Attributes table --
|
|
const attribTable = document.getElementById(`charAttribTable-${esc}`);
|
|
if (attribTable && data.attributes) {
|
|
const order = ['strength', 'endurance', 'coordination', 'quickness', 'focus', 'self'];
|
|
const rows = attribTable.querySelectorAll('tr:not(.ts-colnames)');
|
|
order.forEach((attr, i) => {
|
|
if (rows[i] && data.attributes[attr]) {
|
|
const cells = rows[i].querySelectorAll('td');
|
|
if (cells[1]) cells[1].textContent = data.attributes[attr].creation ?? '\u2014';
|
|
if (cells[2]) cells[2].textContent = data.attributes[attr].base ?? '\u2014';
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- Vitals table (base values) --
|
|
const vitalsTable = document.getElementById(`charVitalsTable-${esc}`);
|
|
if (vitalsTable && data.vitals) {
|
|
const vOrder = ['health', 'stamina', 'mana'];
|
|
const vRows = vitalsTable.querySelectorAll('tr:not(.ts-colnames)');
|
|
vOrder.forEach((v, i) => {
|
|
if (vRows[i] && data.vitals[v]) {
|
|
const cells = vRows[i].querySelectorAll('td');
|
|
if (cells[1]) cells[1].textContent = data.vitals[v].base ?? '\u2014';
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- Skill credits --
|
|
const creditsTable = document.getElementById(`charCredits-${esc}`);
|
|
if (creditsTable) {
|
|
const cell = creditsTable.querySelector('td.ts-headerright');
|
|
if (cell) cell.textContent = fmt(data.skill_credits);
|
|
}
|
|
|
|
// -- Skills tab --
|
|
const skillsBox = document.getElementById(`charSkills-${esc}`);
|
|
if (skillsBox && data.skills) {
|
|
const grouped = { Specialized: [], Trained: [] };
|
|
for (const [skill, info] of Object.entries(data.skills)) {
|
|
const training = info.training || 'Untrained';
|
|
if (training === 'Untrained' || training === 'Unusable') continue;
|
|
const displayName = skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
if (grouped[training]) grouped[training].push({ name: displayName, base: info.base });
|
|
}
|
|
for (const g of Object.values(grouped)) g.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
let html = '<table class="ts-char">';
|
|
html += '<tr class="ts-colnames"><td>Skill</td><td>Level</td></tr>';
|
|
if (grouped.Specialized.length) {
|
|
for (const s of grouped.Specialized) {
|
|
html += `<tr><td class="ts-specialized">${s.name}</td><td class="ts-specialized" style="text-align:right">${s.base}</td></tr>`;
|
|
}
|
|
}
|
|
if (grouped.Trained.length) {
|
|
for (const s of grouped.Trained) {
|
|
html += `<tr><td class="ts-trained">${s.name}</td><td class="ts-trained" style="text-align:right">${s.base}</td></tr>`;
|
|
}
|
|
}
|
|
html += '</table>';
|
|
skillsBox.innerHTML = html;
|
|
}
|
|
|
|
// -- Titles tab --
|
|
const titlesBox = document.getElementById(`charTitles-${esc}`);
|
|
if (titlesBox) {
|
|
const statsData = data.stats_data || data;
|
|
const titles = statsData.titles;
|
|
if (titles && titles.length > 0) {
|
|
let html = '<div class="ts-titles-list">';
|
|
for (const t of titles) html += `<div>${t}</div>`;
|
|
html += '</div>';
|
|
titlesBox.innerHTML = html;
|
|
} else {
|
|
titlesBox.innerHTML = '<div class="ts-placeholder">No titles data</div>';
|
|
}
|
|
}
|
|
|
|
// -- Properties-based tabs (Augmentations, Ratings, Other) --
|
|
const statsData = data.stats_data || data;
|
|
const props = statsData.properties || {};
|
|
|
|
// Augmentations tab
|
|
const augsBox = document.getElementById(`charAugs-${esc}`);
|
|
if (augsBox) {
|
|
let augRows = [], auraRows = [];
|
|
for (const [id, val] of Object.entries(props)) {
|
|
const nid = parseInt(id);
|
|
if (TS_AUGMENTATIONS[nid] && val > 0) augRows.push({ name: TS_AUGMENTATIONS[nid], uses: val });
|
|
if (TS_AURAS[nid] && val > 0) auraRows.push({ name: TS_AURAS[nid], uses: val });
|
|
}
|
|
if (augRows.length || auraRows.length) {
|
|
let html = '';
|
|
if (augRows.length) {
|
|
html += '<div class="ts-section-title">Augmentations</div>';
|
|
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
|
|
for (const a of augRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
|
|
html += '</table>';
|
|
}
|
|
if (auraRows.length) {
|
|
html += '<div class="ts-section-title">Auras</div>';
|
|
html += '<table class="ts-props"><tr class="ts-colnames"><td>Name</td><td>Uses</td></tr>';
|
|
for (const a of auraRows) html += `<tr><td>${a.name}</td><td style="text-align:right">${a.uses}</td></tr>`;
|
|
html += '</table>';
|
|
}
|
|
augsBox.innerHTML = html;
|
|
} else {
|
|
augsBox.innerHTML = '<div class="ts-placeholder">No augmentation data</div>';
|
|
}
|
|
}
|
|
|
|
// Ratings tab
|
|
const ratingsBox = document.getElementById(`charRatings-${esc}`);
|
|
if (ratingsBox) {
|
|
let rows = [];
|
|
for (const [id, val] of Object.entries(props)) {
|
|
const nid = parseInt(id);
|
|
if (TS_RATINGS[nid] && val > 0) rows.push({ name: TS_RATINGS[nid], value: val });
|
|
}
|
|
if (rows.length) {
|
|
let html = '<table class="ts-props"><tr class="ts-colnames"><td>Rating</td><td>Value</td></tr>';
|
|
for (const r of rows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
|
|
html += '</table>';
|
|
ratingsBox.innerHTML = html;
|
|
} else {
|
|
ratingsBox.innerHTML = '<div class="ts-placeholder">No rating data</div>';
|
|
}
|
|
}
|
|
|
|
// Other tab (General, Masteries, Society)
|
|
const otherBox = document.getElementById(`charOther-${esc}`);
|
|
if (otherBox) {
|
|
let html = '';
|
|
|
|
// General section
|
|
let generalRows = [];
|
|
if (data.birth) generalRows.push({ name: 'Birth', value: data.birth });
|
|
if (data.deaths != null) generalRows.push({ name: 'Deaths', value: fmt(data.deaths) });
|
|
for (const [id, val] of Object.entries(props)) {
|
|
const nid = parseInt(id);
|
|
if (TS_GENERAL[nid]) generalRows.push({ name: TS_GENERAL[nid], value: val });
|
|
}
|
|
if (generalRows.length) {
|
|
html += '<div class="ts-section-title">General</div>';
|
|
html += '<table class="ts-props">';
|
|
for (const r of generalRows) html += `<tr><td>${r.name}</td><td style="text-align:right">${r.value}</td></tr>`;
|
|
html += '</table>';
|
|
}
|
|
|
|
// Masteries section
|
|
let masteryRows = [];
|
|
for (const [id, val] of Object.entries(props)) {
|
|
const nid = parseInt(id);
|
|
if (TS_MASTERIES[nid]) {
|
|
const mName = TS_MASTERY_NAMES[val] || `Unknown (${val})`;
|
|
masteryRows.push({ name: TS_MASTERIES[nid], value: mName });
|
|
}
|
|
}
|
|
if (masteryRows.length) {
|
|
html += '<div class="ts-section-title">Masteries</div>';
|
|
html += '<table class="ts-props">';
|
|
for (const m of masteryRows) html += `<tr><td>${m.name}</td><td style="text-align:right">${m.value}</td></tr>`;
|
|
html += '</table>';
|
|
}
|
|
|
|
// Society section
|
|
let societyRows = [];
|
|
for (const [id, val] of Object.entries(props)) {
|
|
const nid = parseInt(id);
|
|
if (TS_SOCIETY[nid] && val > 0) {
|
|
societyRows.push({ name: TS_SOCIETY[nid], rank: _tsSocietyRank(val), value: val });
|
|
}
|
|
}
|
|
if (societyRows.length) {
|
|
html += '<div class="ts-section-title">Society</div>';
|
|
html += '<table class="ts-props">';
|
|
for (const s of societyRows) html += `<tr><td>${s.name}</td><td style="text-align:right">${s.rank} (${s.value})</td></tr>`;
|
|
html += '</table>';
|
|
}
|
|
|
|
otherBox.innerHTML = html || '<div class="ts-placeholder">No additional data</div>';
|
|
}
|
|
|
|
// -- Allegiance section --
|
|
const allegDiv = document.getElementById(`charAllegiance-${esc}`);
|
|
if (allegDiv && data.allegiance) {
|
|
const a = data.allegiance;
|
|
let html = '<div class="ts-section-title">Allegiance</div>';
|
|
html += '<table class="ts-allegiance">';
|
|
if (a.name) html += `<tr><td>Name</td><td>${a.name}</td></tr>`;
|
|
if (a.monarch) html += `<tr><td>Monarch</td><td>${a.monarch.name || '\u2014'}</td></tr>`;
|
|
if (a.patron) html += `<tr><td>Patron</td><td>${a.patron.name || '\u2014'}</td></tr>`;
|
|
if (a.rank !== undefined) html += `<tr><td>Rank</td><td>${a.rank}</td></tr>`;
|
|
if (a.followers !== undefined) html += `<tr><td>Followers</td><td>${a.followers}</td></tr>`;
|
|
html += '</table>';
|
|
allegDiv.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
function updateCharacterVitals(name, vitals) {
|
|
const esc = CSS.escape(name);
|
|
const vitalsDiv = document.getElementById(`charVitals-${esc}`);
|
|
if (!vitalsDiv) return;
|
|
|
|
const vitalElements = vitalsDiv.querySelectorAll('.ts-vital');
|
|
|
|
if (vitalElements[0]) {
|
|
const fill = vitalElements[0].querySelector('.ts-vital-fill');
|
|
const txt = vitalElements[0].querySelector('.ts-vital-text');
|
|
if (fill) fill.style.width = `${vitals.health_percentage || 0}%`;
|
|
if (txt && vitals.health_current !== undefined) {
|
|
txt.textContent = `${vitals.health_current} / ${vitals.health_max}`;
|
|
}
|
|
}
|
|
if (vitalElements[1]) {
|
|
const fill = vitalElements[1].querySelector('.ts-vital-fill');
|
|
const txt = vitalElements[1].querySelector('.ts-vital-text');
|
|
if (fill) fill.style.width = `${vitals.stamina_percentage || 0}%`;
|
|
if (txt && vitals.stamina_current !== undefined) {
|
|
txt.textContent = `${vitals.stamina_current} / ${vitals.stamina_max}`;
|
|
}
|
|
}
|
|
if (vitalElements[2]) {
|
|
const fill = vitalElements[2].querySelector('.ts-vital-fill');
|
|
const txt = vitalElements[2].querySelector('.ts-vital-text');
|
|
if (fill) fill.style.width = `${vitals.mana_percentage || 0}%`;
|
|
if (txt && vitals.mana_current !== undefined) {
|
|
txt.textContent = `${vitals.mana_current} / ${vitals.mana_max}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
let pendingFrame = null;
|
|
function scheduleViewUpdate() {
|
|
if (!pendingFrame) {
|
|
pendingFrame = requestAnimationFrame(() => {
|
|
updateView();
|
|
pendingFrame = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
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) {
|
|
handleError('Player update', e);
|
|
}
|
|
}
|
|
|
|
async function pollTotalRares() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/total-rares/`);
|
|
const data = await response.json();
|
|
updateTotalRaresDisplay(data);
|
|
} catch (e) {
|
|
handleError('Rare counter', 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 pollTotalKills() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/total-kills/`);
|
|
const data = await response.json();
|
|
updateTotalKillsDisplay(data);
|
|
} catch (e) {
|
|
handleError('Kill counter', e);
|
|
}
|
|
}
|
|
|
|
function updateTotalKillsDisplay(data) {
|
|
const killsElement = document.getElementById('totalKillsCount');
|
|
if (killsElement && data.total !== undefined) {
|
|
killsElement.textContent = data.total.toLocaleString();
|
|
}
|
|
}
|
|
|
|
async function pollServerHealth() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/server-health`);
|
|
const data = await response.json();
|
|
updateServerStatusDisplay(data);
|
|
} catch (e) {
|
|
handleError('Server health', 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
|
|
debugLog(`Server Status: ${msg.message}`);
|
|
}
|
|
|
|
// Trigger an immediate server health poll to refresh the display
|
|
pollServerHealth();
|
|
}
|
|
|
|
function startPolling() {
|
|
// Clear any existing intervals first (prevents leak on re-init)
|
|
pollIntervals.forEach(id => clearInterval(id));
|
|
pollIntervals.length = 0;
|
|
|
|
// Initial fetches
|
|
pollLive();
|
|
pollTotalRares();
|
|
pollTotalKills();
|
|
pollServerHealth();
|
|
|
|
// Set up recurring polls
|
|
pollIntervals.push(setInterval(pollLive, POLL_MS));
|
|
pollIntervals.push(setInterval(pollTotalRares, POLL_RARES_MS));
|
|
pollIntervals.push(setInterval(pollTotalKills, POLL_KILLS_MS));
|
|
pollIntervals.push(setInterval(pollServerHealth, POLL_HEALTH_MS));
|
|
}
|
|
|
|
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();
|
|
initPortalMap();
|
|
};
|
|
|
|
// 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
|
|
filtered.sort(currentSort.comparator);
|
|
const sorted = filtered;
|
|
render(sorted);
|
|
}
|
|
|
|
// Track when user might be interacting to avoid DOM manipulation during clicks
|
|
let userInteracting = false;
|
|
let interactionTimeout = null;
|
|
|
|
// Add global mousedown/mouseup tracking to detect when user is clicking
|
|
document.addEventListener('mousedown', () => {
|
|
userInteracting = true;
|
|
if (interactionTimeout) clearTimeout(interactionTimeout);
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
// Give a small buffer after mouseup to ensure click events complete
|
|
if (interactionTimeout) clearTimeout(interactionTimeout);
|
|
interactionTimeout = setTimeout(() => {
|
|
userInteracting = false;
|
|
}, 50); // 50ms buffer
|
|
});
|
|
|
|
function render(players) {
|
|
const startTime = performance.now();
|
|
debugLog('🔄 RENDER STARTING:', new Date().toISOString());
|
|
|
|
// If user is actively clicking, defer this render briefly
|
|
if (userInteracting) {
|
|
debugLog('🔄 RENDER DEFERRED: User interaction detected');
|
|
setTimeout(() => render(players), 100);
|
|
return;
|
|
}
|
|
|
|
// Reset per-render stats
|
|
performanceStats.renderDotsCreated = 0;
|
|
performanceStats.renderDotsReused = 0;
|
|
performanceStats.renderListItemsCreated = 0;
|
|
performanceStats.renderListItemsReused = 0;
|
|
performanceStats.renderCount++;
|
|
|
|
// Get existing elements and map them by player name for reuse
|
|
const existingDots = Array.from(dots.children);
|
|
const existingListItems = Array.from(list.children);
|
|
|
|
// Create maps for efficient lookup by player name
|
|
const dotsByPlayer = new Map();
|
|
const listItemsByPlayer = new Map();
|
|
|
|
existingDots.forEach(dot => {
|
|
if (dot.playerData && dot.playerData.character_name) {
|
|
dotsByPlayer.set(dot.playerData.character_name, dot);
|
|
}
|
|
});
|
|
|
|
existingListItems.forEach(li => {
|
|
if (li.playerData && li.playerData.character_name) {
|
|
listItemsByPlayer.set(li.playerData.character_name, li);
|
|
}
|
|
});
|
|
|
|
|
|
// DON'T clear containers - we need to reuse elements
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Total kills is now fetched from the /total-kills/ API endpoint
|
|
// (see pollTotalKills function) to include ALL characters, not just online ones
|
|
|
|
players.forEach((p) => {
|
|
const { x, y } = worldToPx(p.ew, p.ns);
|
|
|
|
// Reuse existing dot by player name or create new one
|
|
let dot = dotsByPlayer.get(p.character_name);
|
|
if (!dot) {
|
|
dot = createNewDot();
|
|
dots.appendChild(dot);
|
|
} else {
|
|
performanceStats.dotsReused++;
|
|
performanceStats.renderDotsReused++;
|
|
// Remove from the map so we don't count it as unused later
|
|
dotsByPlayer.delete(p.character_name);
|
|
}
|
|
|
|
// Update dot properties
|
|
dot.style.left = `${x}px`;
|
|
dot.style.top = `${y}px`;
|
|
dot.style.background = getColorFor(p.character_name);
|
|
dot.playerData = p; // Store for event handlers
|
|
|
|
// Update highlight state
|
|
if (p.character_name === selected) {
|
|
dot.classList.add('highlight');
|
|
} else {
|
|
dot.classList.remove('highlight');
|
|
}
|
|
|
|
// Reuse existing list item by player name or create new one
|
|
let li = listItemsByPlayer.get(p.character_name);
|
|
if (!li) {
|
|
li = createNewListItem();
|
|
list.appendChild(li);
|
|
} else {
|
|
performanceStats.listItemsReused++;
|
|
performanceStats.renderListItemsReused++;
|
|
// Remove from the map so we don't count it as unused later
|
|
listItemsByPlayer.delete(p.character_name);
|
|
}
|
|
|
|
const color = getColorFor(p.character_name);
|
|
li.style.borderLeftColor = color;
|
|
li.playerData = p; // Store for event handlers BEFORE any DOM movement
|
|
|
|
// Also store playerData directly on buttons for more reliable access
|
|
if (li.chatBtn) li.chatBtn.playerData = p;
|
|
if (li.statsBtn) li.statsBtn.playerData = p;
|
|
if (li.inventoryBtn) li.inventoryBtn.playerData = p;
|
|
|
|
// Only reorder element if it's actually out of place for current sort order
|
|
// Check if this element needs to be moved to maintain sort order
|
|
const expectedIndex = players.indexOf(p);
|
|
const currentIndex = Array.from(list.children).indexOf(li);
|
|
|
|
if (currentIndex !== expectedIndex && li.parentNode) {
|
|
// Find the correct position to insert
|
|
if (expectedIndex === players.length - 1) {
|
|
// Should be last - only move if it's not already last
|
|
if (li !== list.lastElementChild) {
|
|
list.appendChild(li);
|
|
}
|
|
} else {
|
|
// Should be at a specific position
|
|
const nextPlayer = players[expectedIndex + 1];
|
|
const nextElement = Array.from(list.children).find(el =>
|
|
el.playerData && el.playerData.character_name === nextPlayer.character_name
|
|
);
|
|
if (nextElement && li.nextElementSibling !== nextElement) {
|
|
list.insertBefore(li, nextElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate KPR (Kills Per Rare)
|
|
const playerTotalKills = p.total_kills || 0;
|
|
const totalRares = p.total_rares || 0;
|
|
const kpr = totalRares > 0 ? Math.round(playerTotalKills / totalRares) : '∞';
|
|
|
|
// Update only the grid content via innerHTML (buttons preserved)
|
|
li.gridContent.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();
|
|
metaSpan.classList.remove('green', 'red'); // Clear previous
|
|
if (goodStates.includes(state)) {
|
|
metaSpan.classList.add('green');
|
|
} else {
|
|
metaSpan.classList.add('red');
|
|
}
|
|
}
|
|
|
|
// Update selected state
|
|
if (p.character_name === selected) {
|
|
li.classList.add('selected');
|
|
} else {
|
|
li.classList.remove('selected');
|
|
}
|
|
});
|
|
|
|
// Remove unused elements (any elements left in the maps are unused)
|
|
// These are dots for players that are no longer in the current player list
|
|
dotsByPlayer.forEach((dot, playerName) => {
|
|
dots.removeChild(dot);
|
|
});
|
|
|
|
// These are list items for players that are no longer in the current player list
|
|
listItemsByPlayer.forEach((li, playerName) => {
|
|
list.removeChild(li);
|
|
});
|
|
|
|
// Update performance stats
|
|
performanceStats.lastRenderTime = performance.now() - startTime;
|
|
|
|
// Determine optimization status
|
|
const totalCreatedThisRender = performanceStats.renderDotsCreated + performanceStats.renderListItemsCreated;
|
|
const totalReusedThisRender = performanceStats.renderDotsReused + performanceStats.renderListItemsReused;
|
|
const isOptimized = totalCreatedThisRender === 0 && totalReusedThisRender > 0;
|
|
const isPartiallyOptimized = totalCreatedThisRender < players.length && totalReusedThisRender > 0;
|
|
|
|
// Choose icon and color
|
|
let statusIcon = '🚀';
|
|
let colorStyle = '';
|
|
if (isOptimized) {
|
|
statusIcon = '✨';
|
|
colorStyle = 'background: #1a4a1a; color: #4ade80; font-weight: bold;';
|
|
} else if (isPartiallyOptimized) {
|
|
statusIcon = '⚡';
|
|
colorStyle = 'background: #4a3a1a; color: #fbbf24; font-weight: bold;';
|
|
} else {
|
|
statusIcon = '🔥';
|
|
colorStyle = 'background: #4a1a1a; color: #f87171; font-weight: bold;';
|
|
}
|
|
|
|
// Performance stats are tracked but not logged to keep console clean
|
|
// Optimization is achieving 100% element reuse consistently
|
|
|
|
const renderTime = performance.now() - startTime;
|
|
debugLog('🔄 RENDER COMPLETED:', renderTime.toFixed(2) + 'ms');
|
|
}
|
|
|
|
/* ---------- rendering trails ------------------------------- */
|
|
function renderTrails(trailData) {
|
|
trailsContainer.innerHTML = '';
|
|
// Build point strings directly - avoid intermediate arrays
|
|
const byChar = {};
|
|
for (const pt of trailData) {
|
|
const { x, y } = worldToPx(pt.ew, pt.ns);
|
|
const key = pt.character_name;
|
|
if (!byChar[key]) byChar[key] = { points: `${x},${y}`, count: 1 };
|
|
else { byChar[key].points += ` ${x},${y}`; byChar[key].count++; }
|
|
}
|
|
for (const name in byChar) {
|
|
if (byChar[name].count < 2) continue;
|
|
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
poly.setAttribute('points', byChar[name].points);
|
|
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 === 'character_stats') {
|
|
characterStats[msg.character_name] = msg;
|
|
updateCharacterWindow(msg.character_name, msg);
|
|
} else if (msg.type === 'inventory_delta') {
|
|
updateInventoryLive(msg);
|
|
} else if (msg.type === 'server_status') {
|
|
handleServerStatusUpdate(msg);
|
|
}
|
|
});
|
|
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
|
socket.addEventListener('error', e => handleError('WebSocket', e));
|
|
}
|
|
|
|
// Display or create a chat window for a character
|
|
function showChatWindow(name) {
|
|
debugLog('showChatWindow called for:', name);
|
|
const windowId = `chatWindow-${name}`;
|
|
|
|
const { win, content, isNew } = createWindow(
|
|
windowId, `Chat: ${name}`, 'chat-window'
|
|
);
|
|
|
|
if (!isNew) {
|
|
debugLog('Existing chat window found, showing it');
|
|
return;
|
|
}
|
|
|
|
win.dataset.character = name;
|
|
chatWindows[name] = win;
|
|
|
|
// Messages container
|
|
const msgs = document.createElement('div');
|
|
msgs.className = 'chat-messages';
|
|
content.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 = '';
|
|
});
|
|
content.appendChild(form);
|
|
|
|
debugLog('Chat window created for:', name);
|
|
}
|
|
|
|
// 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;
|
|
scheduleViewUpdate();
|
|
}, { 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;
|
|
scheduleViewUpdate();
|
|
});
|
|
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;
|
|
scheduleViewUpdate();
|
|
});
|
|
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 = {};
|
|
const characterStats = {};
|
|
const characterWindows = {};
|
|
|
|
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,
|
|
health_current: vitalsMsg.health_current,
|
|
health_max: vitalsMsg.health_max,
|
|
stamina_current: vitalsMsg.stamina_current,
|
|
stamina_max: vitalsMsg.stamina_max,
|
|
mana_current: vitalsMsg.mana_current,
|
|
mana_max: vitalsMsg.mana_max,
|
|
vitae: vitalsMsg.vitae
|
|
};
|
|
|
|
// Re-render the player list to update vitals in the UI
|
|
renderList();
|
|
|
|
// Also update character window if open
|
|
updateCharacterVitals(vitalsMsg.character_name, characterVitals[vitalsMsg.character_name]);
|
|
}
|
|
|
|
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 display duration and process next
|
|
setTimeout(() => {
|
|
notifEl.style.animation = 'notification-slide-out 0.5s ease-in forwards';
|
|
setTimeout(() => {
|
|
notifEl.remove();
|
|
processNotificationQueue();
|
|
}, 500);
|
|
}, NOTIFICATION_DURATION_MS);
|
|
}
|
|
|
|
// 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) {
|
|
// Use element pool for O(1) lookup instead of querySelectorAll
|
|
for (const item of elementPools.activeListItems) {
|
|
if (item.playerData && item.playerData.character_name === characterName) {
|
|
item.classList.add('rare-finder-glow');
|
|
setTimeout(() => {
|
|
item.classList.remove('rare-finder-glow');
|
|
}, GLOW_DURATION_MS);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
debugLog(`🏆 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');
|
|
}
|
|
|
|
/**
|
|
* Opens the Quest Status interface in a new browser tab.
|
|
*/
|
|
function openQuestStatus() {
|
|
// Open the Quest Status page in a new tab
|
|
window.open('/quest-status.html', '_blank');
|
|
}
|
|
|
|
/**
|
|
* Opens the Player Dashboard interface in a new browser tab.
|
|
*/
|
|
function openPlayerDashboard() {
|
|
// Open the Player Dashboard page in a new tab
|
|
window.open('/player-dashboard.html', '_blank');
|
|
}
|
|
|
|
|