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;
+}