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:
parent
c0da36c280
commit
3852cf205e
3 changed files with 439 additions and 1 deletions
9
main.py
9
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]
|
||||
|
|
|
|||
301
static/script.js
301
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 = '<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];
|
||||
}
|
||||
|
|
|
|||
130
static/style.css
130
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue