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:
Erik 2026-04-08 13:16:36 +02:00
parent 2f21159acb
commit b941a29f04
2 changed files with 114 additions and 39 deletions

18
main.py
View file

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

View file

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