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