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_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 }
|
||||
|
|
|
|||
135
static/script.js
135
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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue