diff --git a/main.py b/main.py index 054a71fc..24f9ccba 100644 --- a/main.py +++ b/main.py @@ -982,6 +982,7 @@ live_vitals: Dict[str, dict] = {} live_character_stats: Dict[str, dict] = {} live_equipment_cantrip_states: 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 = "your_shared_secret" @@ -2685,6 +2686,14 @@ async def ws_receive_snapshots( live_nearby_objects[character_name] = data await _broadcast_to_browser_clients(data) 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 if msg_type: logger.warning( @@ -2800,6 +2809,15 @@ async def ws_live_updates(websocket: WebSocket): except WebSocketDisconnect: logger.info(f"Browser WebSocket disconnected: {websocket.client}") 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) if "player_name" in data and "command" in data: # New format: { player_name, command } diff --git a/static/script.js b/static/script.js index ab3dd9cf..db2a6ed6 100644 --- a/static/script.js +++ b/static/script.js @@ -3067,6 +3067,13 @@ function initWebSocket() { handleServerStatusUpdate(msg); } else if (msg.type === 'nearby_objects') { 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)); @@ -3578,6 +3585,7 @@ function openPlayerDashboard() { // ─── Radar Window ────────────────────────────────────────────────── const radarWindows = {}; // character_name -> window element +const dungeonMapCache = {}; // landblock hex string -> dungeon map data const RADAR_COLORS = { Monster: '#ff4444', @@ -3669,6 +3677,8 @@ function showRadarWindow(name) { win._radarSelectedId = null; win._radarLastObjects = []; // cache for click hit-testing win._radarMapImg = radarMapImg; + win._radarIsDungeon = false; + win._radarDungeonLandblock = null; // Scroll-wheel zoom on canvas canvas.addEventListener('wheel', (e) => { @@ -3715,13 +3725,29 @@ function updateRadarWindow(msg) { const playerEW = msg.player_ew; const playerNS = msg.player_ns; 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 ─── const ctx = canvas.getContext('2d'); const size = RADAR_CANVAS_SIZE; const cx = 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); @@ -3731,38 +3757,57 @@ function updateRadarWindow(msg) { ctx.arc(cx, cy, cx, 0, Math.PI * 2); ctx.fill(); - // Map background (clip to circle, rotate to heading-up) - const mapImg = win._radarMapImg; - if (mapImg && mapImg.complete && mapImg.naturalWidth) { - ctx.save(); - // Clip to radar circle - ctx.beginPath(); - ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2); - ctx.clip(); - // Map coordinate system: -102.1 to 102.1 for both axes - const mapCoordRange = 204.2; - 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 + // Background: overworld map or dungeon cells + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2); + ctx.clip(); + + if (isDungeon && landblock && dungeonMapCache[landblock]) { + // ─── Dungeon cell background ─── + const dmap = dungeonMapCache[landblock]; + const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6; ctx.translate(cx, cy); ctx.rotate(-playerHeading * Math.PI / 180); // heading-up rotation - ctx.globalAlpha = 0.4; - const srcSize = range * pixPerCoord * 2; - ctx.drawImage(mapImg, - mapPx - srcSize / 2, mapPy - srcSize / 2, srcSize, srcSize, - -cx, -cy, size, size - ); + const cellSize = 10 * scale; // each cell is 10 game units + + (dmap.z_levels || []).forEach(level => { + const isCurrentFloor = (level.z === playerRoundedZ); + 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.restore(); - // Re-clip for remaining draws - ctx.beginPath(); - ctx.arc(cx, cy, cx, 0, Math.PI * 2); - ctx.clip(); + ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform + } else if (!isDungeon) { + // ─── Overworld map background ─── + const mapImg = win._radarMapImg; + 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 ctx.strokeStyle = '#333'; @@ -3817,11 +3862,18 @@ function updateRadarWindow(msg) { const cosA = Math.cos(rotAngle); const sinA = Math.sin(rotAngle); objects.forEach(obj => { - const dEW = obj.ew - playerEW; - const dNS = obj.ns - playerNS; + // Use raw physics coords in dungeons, EW/NS on surface + 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 dy = -(dEW * sinA + dNS * cosA); + const dx = dX * cosA - dY * sinA; + const dy = -(dX * sinA + dY * cosA); const px = cx + dx * scale; const py = cy + dy * scale; @@ -3874,14 +3926,19 @@ function updateRadarWindow(msg) { // ─── 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; + let distMeters, dX, dY; + if (isDungeon && obj.raw_x !== undefined) { + dX = obj.raw_x - playerX; + dY = obj.raw_y - playerY; + 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 - const angle = Math.atan2(dEW, dNS) * 180 / Math.PI; + const angle = Math.atan2(dX, dY) * 180 / Math.PI; const dir = compassDir(angle); return { ...obj, distMeters, dir };