feat: add dungeon map streaming and rendering in radar
- Backend: dungeon_map event handler with permanent in-memory cache by landblock ID, request_dungeon_map for late-joining browsers - Frontend: render dungeon cells as colored rectangles when in dungeon, multi-level Z support (current floor bright, others dimmed), automatic overworld/dungeon switching based on is_dungeon flag, raw physics coordinate positioning for dungeon objects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f21159acb
commit
b941a29f04
2 changed files with 114 additions and 39 deletions
18
main.py
18
main.py
|
|
@ -982,6 +982,7 @@ live_vitals: Dict[str, dict] = {}
|
||||||
live_character_stats: Dict[str, dict] = {}
|
live_character_stats: Dict[str, dict] = {}
|
||||||
live_equipment_cantrip_states: Dict[str, dict] = {}
|
live_equipment_cantrip_states: Dict[str, dict] = {}
|
||||||
live_nearby_objects: Dict[str, dict] = {}
|
live_nearby_objects: Dict[str, dict] = {}
|
||||||
|
dungeon_map_cache: Dict[str, dict] = {} # landblock hex string -> dungeon map data
|
||||||
|
|
||||||
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
||||||
SHARED_SECRET = "your_shared_secret"
|
SHARED_SECRET = "your_shared_secret"
|
||||||
|
|
@ -2685,6 +2686,14 @@ async def ws_receive_snapshots(
|
||||||
live_nearby_objects[character_name] = data
|
live_nearby_objects[character_name] = data
|
||||||
await _broadcast_to_browser_clients(data)
|
await _broadcast_to_browser_clients(data)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if msg_type == "dungeon_map":
|
||||||
|
landblock = data.get("landblock")
|
||||||
|
if landblock:
|
||||||
|
dungeon_map_cache[landblock] = data
|
||||||
|
logger.info(f"Cached dungeon map for {landblock} ({len(data.get('z_levels', []))} z-levels)")
|
||||||
|
await _broadcast_to_browser_clients(data)
|
||||||
|
continue
|
||||||
# Unknown message types are ignored
|
# Unknown message types are ignored
|
||||||
if msg_type:
|
if msg_type:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -2800,6 +2809,15 @@ async def ws_live_updates(websocket: WebSocket):
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info(f"Browser WebSocket disconnected: {websocket.client}")
|
logger.info(f"Browser WebSocket disconnected: {websocket.client}")
|
||||||
break
|
break
|
||||||
|
# Handle dungeon map requests from browser
|
||||||
|
if data.get("type") == "request_dungeon_map":
|
||||||
|
landblock = data.get("landblock")
|
||||||
|
cached = dungeon_map_cache.get(landblock)
|
||||||
|
if cached:
|
||||||
|
await websocket.send_json(cached)
|
||||||
|
logger.debug(f"Sent cached dungeon map {landblock} to browser")
|
||||||
|
continue
|
||||||
|
|
||||||
# Determine command envelope format (new or legacy)
|
# Determine command envelope format (new or legacy)
|
||||||
if "player_name" in data and "command" in data:
|
if "player_name" in data and "command" in data:
|
||||||
# New format: { player_name, command }
|
# New format: { player_name, command }
|
||||||
|
|
|
||||||
135
static/script.js
135
static/script.js
|
|
@ -3067,6 +3067,13 @@ function initWebSocket() {
|
||||||
handleServerStatusUpdate(msg);
|
handleServerStatusUpdate(msg);
|
||||||
} else if (msg.type === 'nearby_objects') {
|
} else if (msg.type === 'nearby_objects') {
|
||||||
updateRadarWindow(msg);
|
updateRadarWindow(msg);
|
||||||
|
} else if (msg.type === 'dungeon_map') {
|
||||||
|
if (msg.landblock) {
|
||||||
|
dungeonMapCache[msg.landblock] = msg;
|
||||||
|
// Update active radar window for this character
|
||||||
|
const rw = radarWindows[msg.character_name];
|
||||||
|
if (rw) rw._radarDungeonLandblock = msg.landblock;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
||||||
|
|
@ -3578,6 +3585,7 @@ function openPlayerDashboard() {
|
||||||
// ─── Radar Window ──────────────────────────────────────────────────
|
// ─── Radar Window ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const radarWindows = {}; // character_name -> window element
|
const radarWindows = {}; // character_name -> window element
|
||||||
|
const dungeonMapCache = {}; // landblock hex string -> dungeon map data
|
||||||
|
|
||||||
const RADAR_COLORS = {
|
const RADAR_COLORS = {
|
||||||
Monster: '#ff4444',
|
Monster: '#ff4444',
|
||||||
|
|
@ -3669,6 +3677,8 @@ function showRadarWindow(name) {
|
||||||
win._radarSelectedId = null;
|
win._radarSelectedId = null;
|
||||||
win._radarLastObjects = []; // cache for click hit-testing
|
win._radarLastObjects = []; // cache for click hit-testing
|
||||||
win._radarMapImg = radarMapImg;
|
win._radarMapImg = radarMapImg;
|
||||||
|
win._radarIsDungeon = false;
|
||||||
|
win._radarDungeonLandblock = null;
|
||||||
|
|
||||||
// Scroll-wheel zoom on canvas
|
// Scroll-wheel zoom on canvas
|
||||||
canvas.addEventListener('wheel', (e) => {
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
|
@ -3715,13 +3725,29 @@ function updateRadarWindow(msg) {
|
||||||
const playerEW = msg.player_ew;
|
const playerEW = msg.player_ew;
|
||||||
const playerNS = msg.player_ns;
|
const playerNS = msg.player_ns;
|
||||||
const playerHeading = msg.player_heading || 0;
|
const playerHeading = msg.player_heading || 0;
|
||||||
|
const isDungeon = msg.is_dungeon || false;
|
||||||
|
const landblock = msg.landblock || null;
|
||||||
|
const playerX = msg.player_x || 0; // raw physics X (dungeon)
|
||||||
|
const playerY = msg.player_y || 0; // raw physics Y (dungeon)
|
||||||
|
const playerRawZ = msg.player_raw_z || 0;
|
||||||
|
|
||||||
|
// Update dungeon state
|
||||||
|
win._radarIsDungeon = isDungeon;
|
||||||
|
if (isDungeon && landblock) {
|
||||||
|
win._radarDungeonLandblock = landblock;
|
||||||
|
// Request dungeon map if not cached
|
||||||
|
if (!dungeonMapCache[landblock] && socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'request_dungeon_map', landblock }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Draw radar canvas ───
|
// ─── Draw radar canvas ───
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const size = RADAR_CANVAS_SIZE;
|
const size = RADAR_CANVAS_SIZE;
|
||||||
const cx = size / 2;
|
const cx = size / 2;
|
||||||
const cy = size / 2;
|
const cy = size / 2;
|
||||||
const scale = (size / 2) / range;
|
// In dungeons, raw coords are game units; range is AC units (1 AC unit = 240 game units)
|
||||||
|
const scale = isDungeon ? (size / 2) / (range * 240) : (size / 2) / range;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, size, size);
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
|
@ -3731,38 +3757,57 @@ function updateRadarWindow(msg) {
|
||||||
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
|
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Map background (clip to circle, rotate to heading-up)
|
// Background: overworld map or dungeon cells
|
||||||
const mapImg = win._radarMapImg;
|
ctx.save();
|
||||||
if (mapImg && mapImg.complete && mapImg.naturalWidth) {
|
ctx.beginPath();
|
||||||
ctx.save();
|
ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
|
||||||
// Clip to radar circle
|
ctx.clip();
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
|
if (isDungeon && landblock && dungeonMapCache[landblock]) {
|
||||||
ctx.clip();
|
// ─── Dungeon cell background ───
|
||||||
// Map coordinate system: -102.1 to 102.1 for both axes
|
const dmap = dungeonMapCache[landblock];
|
||||||
const mapCoordRange = 204.2;
|
const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6;
|
||||||
const pixPerCoord = mapImg.naturalWidth / mapCoordRange;
|
|
||||||
// Player position in map pixel space
|
|
||||||
const mapPx = (playerEW + 102.1) * pixPerCoord;
|
|
||||||
const mapPy = (102.1 - playerNS) * pixPerCoord; // Y flipped (north = top)
|
|
||||||
// How many map pixels correspond to the radar range
|
|
||||||
const mapPixelsPerRadarPixel = (range * pixPerCoord) / (size / 2);
|
|
||||||
// Draw rotated map centered on player
|
|
||||||
ctx.translate(cx, cy);
|
ctx.translate(cx, cy);
|
||||||
ctx.rotate(-playerHeading * Math.PI / 180); // heading-up rotation
|
ctx.rotate(-playerHeading * Math.PI / 180); // heading-up rotation
|
||||||
ctx.globalAlpha = 0.4;
|
const cellSize = 10 * scale; // each cell is 10 game units
|
||||||
const srcSize = range * pixPerCoord * 2;
|
|
||||||
ctx.drawImage(mapImg,
|
(dmap.z_levels || []).forEach(level => {
|
||||||
mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize,
|
const isCurrentFloor = (level.z === playerRoundedZ);
|
||||||
-cx, -cy, size, size
|
ctx.globalAlpha = isCurrentFloor ? 0.5 : 0.15;
|
||||||
);
|
ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a';
|
||||||
|
ctx.strokeStyle = isCurrentFloor ? '#4a6a4a' : '#2a3a2a';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
|
||||||
|
(level.cells || []).forEach(cell => {
|
||||||
|
const dx = (cell.x - playerX) * scale;
|
||||||
|
const dy = -(cell.y - playerY) * scale; // Y flipped
|
||||||
|
ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
|
||||||
|
ctx.strokeRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
|
||||||
|
});
|
||||||
|
});
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
ctx.restore();
|
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform
|
||||||
// Re-clip for remaining draws
|
} else if (!isDungeon) {
|
||||||
ctx.beginPath();
|
// ─── Overworld map background ───
|
||||||
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
|
const mapImg = win._radarMapImg;
|
||||||
ctx.clip();
|
if (mapImg && mapImg.complete && mapImg.naturalWidth) {
|
||||||
|
const mapCoordRange = 204.2;
|
||||||
|
const pixPerCoord = mapImg.naturalWidth / mapCoordRange;
|
||||||
|
const mapPx = (playerEW + 102.1) * pixPerCoord;
|
||||||
|
const mapPy = (102.1 - playerNS) * pixPerCoord;
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(-playerHeading * Math.PI / 180);
|
||||||
|
ctx.globalAlpha = 0.4;
|
||||||
|
const srcSize = range * pixPerCoord * 2;
|
||||||
|
ctx.drawImage(mapImg,
|
||||||
|
mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize,
|
||||||
|
-cx, -cy, size, size
|
||||||
|
);
|
||||||
|
ctx.globalAlpha = 1.0;
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
// Range rings
|
// Range rings
|
||||||
ctx.strokeStyle = '#333';
|
ctx.strokeStyle = '#333';
|
||||||
|
|
@ -3817,11 +3862,18 @@ function updateRadarWindow(msg) {
|
||||||
const cosA = Math.cos(rotAngle);
|
const cosA = Math.cos(rotAngle);
|
||||||
const sinA = Math.sin(rotAngle);
|
const sinA = Math.sin(rotAngle);
|
||||||
objects.forEach(obj => {
|
objects.forEach(obj => {
|
||||||
const dEW = obj.ew - playerEW;
|
// Use raw physics coords in dungeons, EW/NS on surface
|
||||||
const dNS = obj.ns - playerNS;
|
let dX, dY;
|
||||||
|
if (isDungeon && obj.raw_x !== undefined) {
|
||||||
|
dX = obj.raw_x - playerX;
|
||||||
|
dY = obj.raw_y - playerY;
|
||||||
|
} else {
|
||||||
|
dX = obj.ew - playerEW;
|
||||||
|
dY = obj.ns - playerNS;
|
||||||
|
}
|
||||||
|
|
||||||
const dx = dEW * cosA - dNS * sinA;
|
const dx = dX * cosA - dY * sinA;
|
||||||
const dy = -(dEW * sinA + dNS * cosA);
|
const dy = -(dX * sinA + dY * cosA);
|
||||||
|
|
||||||
const px = cx + dx * scale;
|
const px = cx + dx * scale;
|
||||||
const py = cy + dy * scale;
|
const py = cy + dy * scale;
|
||||||
|
|
@ -3874,14 +3926,19 @@ function updateRadarWindow(msg) {
|
||||||
// ─── Update entity list ───
|
// ─── Update entity list ───
|
||||||
// Sort by distance
|
// Sort by distance
|
||||||
const withDist = objects.map(obj => {
|
const withDist = objects.map(obj => {
|
||||||
const dEW = obj.ew - playerEW;
|
let distMeters, dX, dY;
|
||||||
const dNS = obj.ns - playerNS;
|
if (isDungeon && obj.raw_x !== undefined) {
|
||||||
// Convert coordinate delta to approximate meters (1 AC coord unit = 240 game units ≈ 240m)
|
dX = obj.raw_x - playerX;
|
||||||
const distCoord = Math.sqrt(dEW * dEW + dNS * dNS);
|
dY = obj.raw_y - playerY;
|
||||||
const distMeters = distCoord * 240;
|
distMeters = Math.sqrt(dX * dX + dY * dY); // raw coords are ~meters
|
||||||
|
} else {
|
||||||
|
dX = obj.ew - playerEW;
|
||||||
|
dY = obj.ns - playerNS;
|
||||||
|
distMeters = Math.sqrt(dX * dX + dY * dY) * 240;
|
||||||
|
}
|
||||||
|
|
||||||
// Compass direction
|
// Compass direction
|
||||||
const angle = Math.atan2(dEW, dNS) * 180 / Math.PI;
|
const angle = Math.atan2(dX, dY) * 180 / Math.PI;
|
||||||
const dir = compassDir(angle);
|
const dir = compassDir(angle);
|
||||||
|
|
||||||
return { ...obj, distMeters, dir };
|
return { ...obj, distMeters, dir };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue