Add real-time radar feature for nearby objects

Browser can open a radar window per character that streams nearby
monsters, players, NPCs, portals, and other objects in real-time.
On-demand activation via start_radar/stop_radar commands through
the existing WebSocket command channel.

- Backend: nearby_objects event handler with in-memory cache and broadcast
- Frontend: canvas mini-map + entity list table in draggable window
- Radar button added to player list alongside Chat/Stats/Inventory/Char

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-07 23:00:53 +02:00
parent c0da36c280
commit 3852cf205e
3 changed files with 439 additions and 1 deletions

View file

@ -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]

View file

@ -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 = '<span class="re-color"></span><span class="re-name">Name</span><span class="re-type">Type</span><span class="re-dist">Dist</span><span class="re-dir">Dir</span>';
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];
}

View file

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