754 lines
24 KiB
JavaScript
754 lines
24 KiB
JavaScript
/*
|
||
* script.js - Frontend logic for Dereth Tracker Single-Page Application.
|
||
* Handles WebSocket communication, UI rendering of player lists, map display,
|
||
* and user interactions (filtering, sorting, chat, stats windows).
|
||
*/
|
||
/**
|
||
* script.js - Frontend controller for Dereth Tracker SPA
|
||
*
|
||
* Responsibilities:
|
||
* - Establish WebSocket connections to receive live telemetry and chat data
|
||
* - Fetch and render live player lists, trails, and map dots
|
||
* - Handle user interactions: filtering, sorting, selecting players
|
||
* - Manage dynamic UI components: chat windows, stats panels, tooltips
|
||
* - Provide smooth pan/zoom of map overlay using CSS transforms
|
||
*
|
||
* Structure:
|
||
* 1. DOM references and constant definitions
|
||
* 2. Color palette and assignment logic
|
||
* 3. Sorting and filtering setup
|
||
* 4. Utility functions (coordinate mapping, color hashing)
|
||
* 5. UI window creation (stats, chat)
|
||
* 6. Rendering functions for list and map
|
||
* 7. Event listeners for map interactions and WebSocket messages
|
||
*/
|
||
/* ---------- DOM references --------------------------------------- */
|
||
const wrap = document.getElementById('mapContainer');
|
||
const group = document.getElementById('mapGroup');
|
||
const img = document.getElementById('map');
|
||
const dots = document.getElementById('dots');
|
||
const trailsContainer = document.getElementById('trails');
|
||
const list = document.getElementById('playerList');
|
||
const btnContainer = document.getElementById('sortButtons');
|
||
const tooltip = document.getElementById('tooltip');
|
||
// 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 = {};
|
||
|
||
/**
|
||
* ---------- 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 = 10;
|
||
const FOCUS_ZOOM = 3; // zoom level when you click a name
|
||
const POLL_MS = 2000;
|
||
const MAP_BOUNDS = {
|
||
west : -102.04,
|
||
east : 102.19,
|
||
north: 102.16,
|
||
south: -102.00
|
||
};
|
||
|
||
// 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
|
||
};
|
||
|
||
/**
|
||
* ---------- 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 = [
|
||
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
||
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'
|
||
];
|
||
// 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: "Kills ↓",
|
||
comparator: (a, b) => b.kills - a.kills
|
||
},
|
||
{
|
||
value: "rares",
|
||
label: "Session Rares ↓",
|
||
comparator: (a, b) => (b.session_rares || 0) - (a.session_rares || 0)
|
||
}
|
||
];
|
||
|
||
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 };
|
||
}
|
||
|
||
// Show or create a stats window for a character
|
||
function showStatsWindow(name) {
|
||
if (statsWindows[name]) {
|
||
const existing = statsWindows[name];
|
||
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);
|
||
// 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;
|
||
// Embed a 2×2 grid of Grafana solo-panel iframes for this character
|
||
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)}` +
|
||
`&theme=light`;
|
||
iframe.setAttribute('title', p.title);
|
||
iframe.width = '350';
|
||
iframe.height = '200';
|
||
iframe.frameBorder = '0';
|
||
iframe.allowFullscreen = true;
|
||
content.appendChild(iframe);
|
||
});
|
||
// Enable dragging of the stats window via its header
|
||
if (!window.__chatZ) window.__chatZ = 10000;
|
||
let drag = false;
|
||
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
|
||
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();
|
||
drag = true;
|
||
bringToFront();
|
||
startX = e.clientX; startY = e.clientY;
|
||
startLeft = win.offsetLeft; startTop = win.offsetTop;
|
||
document.body.classList.add('noselect');
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (!drag) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
win.style.left = `${startLeft + dx}px`;
|
||
win.style.top = `${startTop + dy}px`;
|
||
});
|
||
window.addEventListener('mouseup', () => {
|
||
drag = false;
|
||
document.body.classList.remove('noselect');
|
||
});
|
||
// Touch support for dragging
|
||
header.addEventListener('touchstart', e => {
|
||
if (e.touches.length !== 1 || e.target.closest('button')) return;
|
||
drag = true;
|
||
bringToFront();
|
||
const t = e.touches[0];
|
||
startX = t.clientX; startY = t.clientY;
|
||
startLeft = win.offsetLeft; startTop = win.offsetTop;
|
||
});
|
||
window.addEventListener('touchmove', e => {
|
||
if (!drag || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
const dx = t.clientX - startX;
|
||
const dy = t.clientY - startY;
|
||
win.style.left = `${startLeft + dx}px`;
|
||
win.style.top = `${startTop + dy}px`;
|
||
});
|
||
window.addEventListener('touchend', () => { drag = false; });
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
if (pollID !== null) return;
|
||
pollLive();
|
||
pollID = setInterval(pollLive, POLL_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();
|
||
};
|
||
|
||
/* ---------- 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 = '';
|
||
|
||
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';
|
||
li.innerHTML = `
|
||
<span class="player-name">${p.character_name}</span>
|
||
<span class="player-loc">${loc(p.ns, p.ew)}</span>
|
||
<span class="stat kills">${p.kills}</span>
|
||
<span class="stat kph">${p.kills_per_hour}</span>
|
||
<span class="stat rares">${p.session_rares}/${p.total_rares}</span>
|
||
<span class="stat meta">${p.vt_state}</span>
|
||
<span class="stat onlinetime">${p.onlinetime}</span>
|
||
<span class="stat deaths">${p.deaths}</span>
|
||
`;
|
||
|
||
// Color the metastate pill according to its value
|
||
const metaSpan = li.querySelector('.stat.meta');
|
||
if (metaSpan) {
|
||
const goodStates = ['default', 'default2', 'hunt', 'combat'];
|
||
const state = (p.vt_state || '').toString().toLowerCase();
|
||
if (goodStates.includes(state)) {
|
||
metaSpan.classList.add('green');
|
||
} else {
|
||
metaSpan.classList.add('red');
|
||
}
|
||
}
|
||
|
||
li.addEventListener('click', () => selectPlayer(p, x, y));
|
||
if (p.character_name === selected) li.classList.add('selected');
|
||
// Chat button
|
||
const chatBtn = document.createElement('button');
|
||
chatBtn.className = 'chat-btn';
|
||
chatBtn.textContent = 'Chat';
|
||
chatBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showChatWindow(p.character_name);
|
||
});
|
||
li.appendChild(chatBtn);
|
||
// Stats button
|
||
const statsBtn = document.createElement('button');
|
||
statsBtn.className = 'stats-btn';
|
||
statsBtn.textContent = 'Stats';
|
||
statsBtn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
showStatsWindow(p.character_name);
|
||
});
|
||
li.appendChild(statsBtn);
|
||
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);
|
||
}
|
||
});
|
||
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]) {
|
||
// Restore flex layout when reopening & bring to front
|
||
const existing = chatWindows[name];
|
||
existing.style.display = 'flex';
|
||
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 of the chat window via its header element */
|
||
/* --------------------------------------------------------- */
|
||
|
||
// keep a static counter so newer windows can be brought to front
|
||
if (!window.__chatZ) window.__chatZ = 10000;
|
||
|
||
let drag = false;
|
||
let startX = 0, startY = 0;
|
||
let startLeft = 0, startTop = 0;
|
||
|
||
header.style.cursor = 'move';
|
||
|
||
// bring to front when interacting
|
||
const bringToFront = () => {
|
||
window.__chatZ += 1;
|
||
win.style.zIndex = window.__chatZ;
|
||
};
|
||
|
||
header.addEventListener('mousedown', e => {
|
||
// don't initiate drag when pressing the close button (or other clickable controls)
|
||
if (e.target.closest('button')) return;
|
||
e.preventDefault();
|
||
drag = true;
|
||
bringToFront();
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
// current absolute position
|
||
startLeft = win.offsetLeft;
|
||
startTop = win.offsetTop;
|
||
document.body.classList.add('noselect');
|
||
});
|
||
|
||
window.addEventListener('mousemove', e => {
|
||
if (!drag) return;
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
win.style.left = `${startLeft + dx}px`;
|
||
win.style.top = `${startTop + dy}px`;
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
drag = false;
|
||
document.body.classList.remove('noselect');
|
||
});
|
||
|
||
/* touch support */
|
||
header.addEventListener('touchstart', e => {
|
||
if (e.touches.length !== 1 || e.target.closest('button')) return;
|
||
drag = true;
|
||
bringToFront();
|
||
const t = e.touches[0];
|
||
startX = t.clientX;
|
||
startY = t.clientY;
|
||
startLeft = win.offsetLeft;
|
||
startTop = win.offsetTop;
|
||
});
|
||
|
||
window.addEventListener('touchmove', e => {
|
||
if (!drag || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
const dx = t.clientX - startX;
|
||
const dy = t.clientY - startY;
|
||
win.style.left = `${startLeft + dx}px`;
|
||
win.style.top = `${startTop + dy}px`;
|
||
});
|
||
|
||
window.addEventListener('touchend', () => {
|
||
drag = false;
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
});
|