diff --git a/main.py b/main.py index 48e108c0..054a71fc 100644 --- a/main.py +++ b/main.py @@ -981,6 +981,7 @@ live_snapshots: Dict[str, dict] = {} live_vitals: Dict[str, dict] = {} live_character_stats: Dict[str, dict] = {} live_equipment_cantrip_states: Dict[str, dict] = {} +live_nearby_objects: Dict[str, dict] = {} # Shared secret used to authenticate plugin WebSocket connections (override for production) SHARED_SECRET = "your_shared_secret" @@ -2677,6 +2678,13 @@ async def ws_receive_snapshots( f"Invalid portal message format from {websocket.client}: missing required fields" ) continue + + if msg_type == "nearby_objects": + character_name = data.get("character_name") + if character_name: + live_nearby_objects[character_name] = data + await _broadcast_to_browser_clients(data) + continue # Unknown message types are ignored if msg_type: logger.warning( @@ -2691,6 +2699,7 @@ async def ws_receive_snapshots( for name in disconnected_names: plugin_conns.pop(name, None) live_equipment_cantrip_states.pop(name, None) + live_nearby_objects.pop(name, None) # Clean up any plugin registrations for this socket to_remove = [n for n, ws in plugin_conns.items() if ws is websocket] diff --git a/static/script.js b/static/script.js index b6192b43..14765273 100644 --- a/static/script.js +++ b/static/script.js @@ -170,10 +170,22 @@ function createNewListItem() { } }); + const radarBtn = document.createElement('button'); + radarBtn.className = 'radar-btn'; + radarBtn.textContent = 'Radar'; + radarBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData; + if (playerData) { + showRadarWindow(playerData.character_name); + } + }); + buttonsContainer.appendChild(chatBtn); buttonsContainer.appendChild(statsBtn); buttonsContainer.appendChild(inventoryBtn); buttonsContainer.appendChild(charBtn); + buttonsContainer.appendChild(radarBtn); li.appendChild(buttonsContainer); // Store references for easy access @@ -182,6 +194,7 @@ function createNewListItem() { li.statsBtn = statsBtn; li.inventoryBtn = inventoryBtn; li.charBtn = charBtn; + li.radarBtn = radarBtn; return li; } @@ -2875,6 +2888,7 @@ function render(players) { if (li.chatBtn) li.chatBtn.playerData = p; if (li.statsBtn) li.statsBtn.playerData = p; if (li.inventoryBtn) li.inventoryBtn.playerData = p; + if (li.radarBtn) li.radarBtn.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 @@ -3051,6 +3065,8 @@ function initWebSocket() { updateInventoryLive(msg); } else if (msg.type === 'server_status') { handleServerStatusUpdate(msg); + } else if (msg.type === 'nearby_objects') { + updateRadarWindow(msg); } }); socket.addEventListener('close', () => setTimeout(initWebSocket, 2000)); @@ -3559,4 +3575,289 @@ function openPlayerDashboard() { window.open('/player-dashboard.html', '_blank'); } +// ─── Radar Window ────────────────────────────────────────────────── +const radarWindows = {}; // character_name -> window element + +const RADAR_COLORS = { + Monster: '#ff4444', + Player: '#4488ff', + NPC: '#44cc44', + Vendor: '#44cc44', + Portal: '#aa44ff', + Corpse: '#ff8800', + Container: '#cccc44', + Door: '#888888', +}; + +const RADAR_CANVAS_SIZE = 300; +const RADAR_DEFAULT_RANGE = 0.5; // AC coordinate units (~120m) + +function showRadarWindow(name) { + const windowId = `radarWindow-${name}`; + + const { win, content, isNew } = createWindow( + windowId, `Radar: ${name}`, 'radar-window', { + onClose: () => { + // Send stop_radar command + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ player_name: name, command: 'stop_radar' })); + } + delete radarWindows[name]; + } + } + ); + + if (!isNew) return; + + radarWindows[name] = win; + win.dataset.character = name; + + // Radar controls + const controls = document.createElement('div'); + controls.className = 'radar-controls'; + + const rangeLabel = document.createElement('label'); + rangeLabel.textContent = 'Range: '; + const rangeSelect = document.createElement('select'); + rangeSelect.className = 'radar-range-select'; + [ + { value: '0.2', label: 'Close (~50m)' }, + { value: '0.5', label: 'Medium (~120m)' }, + { value: '1.0', label: 'Far (~240m)' }, + { value: '2.0', label: 'Very Far (~480m)' }, + ].forEach(opt => { + const o = document.createElement('option'); + o.value = opt.value; + o.textContent = opt.label; + if (opt.value === '0.5') o.selected = true; + rangeSelect.appendChild(o); + }); + rangeLabel.appendChild(rangeSelect); + controls.appendChild(rangeLabel); + + const headingToggle = document.createElement('label'); + headingToggle.className = 'radar-heading-toggle'; + const headingCheck = document.createElement('input'); + headingCheck.type = 'checkbox'; + headingCheck.checked = false; + headingToggle.appendChild(headingCheck); + headingToggle.appendChild(document.createTextNode(' Heading-up')); + controls.appendChild(headingToggle); + + content.appendChild(controls); + + // Canvas + const canvas = document.createElement('canvas'); + canvas.className = 'radar-canvas'; + canvas.width = RADAR_CANVAS_SIZE; + canvas.height = RADAR_CANVAS_SIZE; + content.appendChild(canvas); + + // Entity list + const listContainer = document.createElement('div'); + listContainer.className = 'radar-entity-list'; + + const listHeader = document.createElement('div'); + listHeader.className = 'radar-entity-header'; + listHeader.innerHTML = 'NameTypeDistDir'; + listContainer.appendChild(listHeader); + + const listBody = document.createElement('div'); + listBody.className = 'radar-entity-body'; + listContainer.appendChild(listBody); + content.appendChild(listContainer); + + // Store refs on the window + win._radarCanvas = canvas; + win._radarListBody = listBody; + win._radarRangeSelect = rangeSelect; + win._radarHeadingCheck = headingCheck; + + // Send start_radar command + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ player_name: name, command: 'start_radar' })); + } +} + +function updateRadarWindow(msg) { + const name = msg.character_name; + const win = radarWindows[name]; + if (!win || win.style.display === 'none') return; + + const canvas = win._radarCanvas; + const listBody = win._radarListBody; + const range = parseFloat(win._radarRangeSelect.value) || RADAR_DEFAULT_RANGE; + const headingUp = win._radarHeadingCheck.checked; + const objects = msg.objects || []; + + const playerEW = msg.player_ew; + const playerNS = msg.player_ns; + const playerHeading = msg.player_heading || 0; + + // ─── Draw radar canvas ─── + const ctx = canvas.getContext('2d'); + const size = RADAR_CANVAS_SIZE; + const cx = size / 2; + const cy = size / 2; + const scale = (size / 2) / range; + + ctx.clearRect(0, 0, size, size); + + // Background + ctx.fillStyle = '#111'; + ctx.beginPath(); + ctx.arc(cx, cy, cx, 0, Math.PI * 2); + ctx.fill(); + + // Range rings + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + ctx.beginPath(); + ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2); + ctx.stroke(); + } + + // Crosshairs + ctx.strokeStyle = '#333'; + ctx.beginPath(); + ctx.moveTo(cx, 0); ctx.lineTo(cx, size); + ctx.moveTo(0, cy); ctx.lineTo(size, cy); + ctx.stroke(); + + // Heading line (north indicator or player heading) + const headingRad = headingUp ? 0 : (-playerHeading * Math.PI / 180); + ctx.strokeStyle = '#666'; + ctx.lineWidth = 1; + ctx.beginPath(); + const nhx = cx + Math.sin(headingRad) * cx * 0.9; + const nhy = cy - Math.cos(headingRad) * cx * 0.9; + ctx.moveTo(cx, cy); + ctx.lineTo(nhx, nhy); + ctx.stroke(); + + // N label + ctx.fillStyle = '#888'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + const nlx = cx + Math.sin(headingRad) * (cx - 10); + const nly = cy - Math.cos(headingRad) * (cx - 10); + ctx.fillText('N', nlx, nly + 3); + + // Rotation angle for heading-up mode (negate to counter-rotate world) + const rotAngle = headingUp ? (-playerHeading * Math.PI / 180) : 0; + + // Draw objects + objects.forEach(obj => { + const dEW = obj.ew - playerEW; + const dNS = obj.ns - playerNS; + + // Rotate if heading-up + let dx, dy; + if (rotAngle !== 0) { + const cosA = Math.cos(rotAngle); + const sinA = Math.sin(rotAngle); + dx = dEW * cosA - dNS * sinA; + dy = -(dEW * sinA + dNS * cosA); + } else { + dx = dEW; + dy = -dNS; // NS increases north, canvas Y increases down + } + + const px = cx + dx * scale; + const py = cy + dy * scale; + + // Clip to circle + const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); + if (distFromCenter > cx - 4) return; + + const color = RADAR_COLORS[obj.object_class] || '#999'; + const dotSize = (obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3; + + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, py, dotSize, 0, Math.PI * 2); + ctx.fill(); + + // Label for players and portals + if (obj.object_class === 'Player' || obj.object_class === 'Portal') { + ctx.fillStyle = color; + ctx.font = '9px monospace'; + ctx.textAlign = 'left'; + ctx.fillText(obj.name, px + 6, py + 3); + } + }); + + // Player dot (center) + ctx.fillStyle = '#ffcc00'; + ctx.beginPath(); + ctx.arc(cx, cy, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.stroke(); + + // ─── Update entity list ─── + // Sort by distance + const withDist = objects.map(obj => { + const dEW = obj.ew - playerEW; + const dNS = obj.ns - playerNS; + // Convert coordinate delta to approximate meters (1 AC coord unit = 240 game units ≈ 240m) + const distCoord = Math.sqrt(dEW * dEW + dNS * dNS); + const distMeters = distCoord * 240; + + // Compass direction + const angle = Math.atan2(dEW, dNS) * 180 / Math.PI; + const dir = compassDir(angle); + + return { ...obj, distMeters, dir }; + }); + withDist.sort((a, b) => a.distMeters - b.distMeters); + + // Rebuild list (simple approach — runs 1/sec so fine) + listBody.innerHTML = ''; + withDist.forEach(obj => { + const row = document.createElement('div'); + row.className = 'radar-entity-row'; + + const colorDot = document.createElement('span'); + colorDot.className = 're-color'; + colorDot.style.background = RADAR_COLORS[obj.object_class] || '#999'; + row.appendChild(colorDot); + + const nameSpan = document.createElement('span'); + nameSpan.className = 're-name'; + nameSpan.textContent = obj.name; + if (obj.health_pct !== null && obj.health_pct !== undefined) { + nameSpan.textContent += ` (${obj.health_pct}%)`; + } + row.appendChild(nameSpan); + + const typeSpan = document.createElement('span'); + typeSpan.className = 're-type'; + typeSpan.textContent = obj.object_class; + row.appendChild(typeSpan); + + const distSpan = document.createElement('span'); + distSpan.className = 're-dist'; + distSpan.textContent = obj.distMeters < 1000 + ? `${Math.round(obj.distMeters)}m` + : `${(obj.distMeters / 1000).toFixed(1)}km`; + row.appendChild(distSpan); + + const dirSpan = document.createElement('span'); + dirSpan.className = 're-dir'; + dirSpan.textContent = obj.dir; + row.appendChild(dirSpan); + + listBody.appendChild(row); + }); +} + +function compassDir(angleDeg) { + // angleDeg: 0 = N, 90 = E, -90 = W, 180 = S + const a = ((angleDeg % 360) + 360) % 360; + const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + return dirs[Math.round(a / 45) % 8]; +} diff --git a/static/style.css b/static/style.css index 4c8c7c8f..16dddd4f 100644 --- a/static/style.css +++ b/static/style.css @@ -525,7 +525,7 @@ body { margin-top: 4px; } -.chat-window, .stats-window, .inventory-window, .character-window { +.chat-window, .stats-window, .inventory-window, .character-window, .radar-window { position: absolute; top: 10px; /* position window to start just right of the sidebar */ @@ -2502,3 +2502,131 @@ table.ts-allegiance td:first-child { border-top: 1px solid #5a4a24; border-bottom: 1px solid #5a4a24; } + +/* ─── Radar Window ─── */ + +.radar-window { + width: 360px; + height: 560px; +} + +.radar-window .window-content { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.radar-controls { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 8px; + background: #1a1a1a; + border-bottom: 1px solid #333; + font-size: 0.8rem; +} + +.radar-controls label { + display: flex; + align-items: center; + gap: 4px; + color: #ccc; +} + +.radar-range-select { + background: #222; + color: #ccc; + border: 1px solid #444; + padding: 2px 4px; + font-size: 0.75rem; +} + +.radar-canvas { + display: block; + margin: 0 auto; + border-bottom: 1px solid #333; + flex-shrink: 0; +} + +.radar-entity-list { + flex: 1; + overflow-y: auto; + font-size: 0.75rem; + min-height: 0; +} + +.radar-entity-header, +.radar-entity-row { + display: flex; + align-items: center; + padding: 2px 6px; + gap: 4px; +} + +.radar-entity-header { + background: #1a1a1a; + color: #888; + font-weight: bold; + border-bottom: 1px solid #333; + position: sticky; + top: 0; +} + +.radar-entity-row { + border-bottom: 1px solid #222; +} + +.radar-entity-row:hover { + background: #1a1a2a; +} + +.re-color { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.re-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #ddd; +} + +.re-type { + width: 60px; + flex-shrink: 0; + color: #888; +} + +.re-dist { + width: 45px; + flex-shrink: 0; + text-align: right; + color: #aaa; +} + +.re-dir { + width: 24px; + flex-shrink: 0; + text-align: center; + color: #888; +} + +.radar-btn { + background: #553388; + color: #fff; + border: 1px solid #774499; + padding: 1px 6px; + cursor: pointer; + font-size: 0.7rem; + border-radius: 3px; +} + +.radar-btn:hover { + background: #664499; +}