fix(v2): player zoom + dot blink + version + sidebar links + dungeon radar + rare emojis

1. Player click → zoom: clicking a player in sidebar or on map dot
   zooms to their position at 3x zoom, centered on screen. Click
   again to deselect. Uses direct DOM transform (no React state).

2. Selected dot blink: selected player dot gets 10px size + blink
   animation (0.6s step-end infinite) matching v1's .dot.highlight.

3. Version display: fetches /api-version on mount, shows "vX.Y.Z"
   in small text positioned just right of sidebar (fixed, top: 6px).

4. Missing sidebar buttons: added Combat Stats (⚔️) alongside
   existing Issues (📋) and Vital Sharing (🤝) in SidebarWindowButtons.

5. Rare notification: added 🎆 emojis to "LEGENDARY RARE!" title
   matching v1's notification text.

6. Dungeon map in radar — verbatim port from v1 lines 3596-3930:
   - loadDungeonTiles(): fetches dungeon_tiles.json, processes each
     tile image (color remap: UB source colors → display colors,
     white → transparent, black → semi-transparent)
   - cellRotation(): maps rotation values to radians (v1's exact logic)
   - Dungeon rendering: sorts z_levels (current floor on top at 85%
     opacity, others at 12%), draws each cell with per-cell rotation,
     uses processed tile canvases or colored rectangle fallback
   - Requests dungeon map via WebSocket when radar detects dungeon
   - Caches dungeon maps on window.__dungeonMapCache
   - Overworld map: fixed srcSize calculation to use range * pixPerCoord

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 12:04:27 +02:00
parent 76baec33e7
commit a59296867d
13 changed files with 219 additions and 71 deletions

View file

@ -4,6 +4,61 @@ import { DraggableWindow } from './DraggableWindow';
const CANVAS_SIZE = 300;
const DEFAULT_RANGE = 0.5; // AC units, ~120m
// ── Dungeon tile system (verbatim from v1) ──
const UB_TILE_COLORS: Record<string, { r: number; g: number; b: number }> = {
walls: { r: 0, g: 0, b: 255 }, innerWalls: { r: 127, g: 127, b: 255 },
rampedWalls: { r: 77, g: 255, b: 255 }, floors: { r: 0, g: 127, b: 255 },
stairs: { r: 0, g: 63, b: 255 },
};
const DUNGEON_COLORS: Record<string, { r: number; g: number; b: number }> = {
walls: { r: 140, g: 140, b: 180 }, innerWalls: { r: 100, g: 100, b: 140 },
rampedWalls: { r: 120, g: 160, b: 120 }, floors: { r: 60, g: 80, b: 60 },
stairs: { r: 180, g: 160, b: 80 },
};
function processTileImage(img: HTMLImageElement): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = 10; c.height = 10;
const ctx = c.getContext('2d')!;
ctx.drawImage(img, 0, 0, 10, 10);
const imageData = ctx.getImageData(0, 0, 10, 10);
const d = imageData.data;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
if (r > 240 && g > 240 && b > 240) { d[i + 3] = 0; continue; }
let matched = false;
for (const [key, src] of Object.entries(UB_TILE_COLORS)) {
if (Math.abs(r - src.r) < 15 && Math.abs(g - src.g) < 15 && Math.abs(b - src.b) < 15) {
const dst = DUNGEON_COLORS[key]; d[i] = dst.r; d[i + 1] = dst.g; d[i + 2] = dst.b;
matched = true; break;
}
}
if (!matched && r < 15 && g < 15 && b < 15) d[i + 3] = 100;
}
ctx.putImageData(imageData, 0, 0);
return c;
}
function cellRotation(rot: number): number {
if (rot === 1) return Math.PI;
if (rot < -0.70 && rot > -0.8) return Math.PI / 2;
if (rot > 0.70 && rot < 0.8) return -Math.PI / 2;
return 0;
}
let dungeonTileCanvases: Record<string, HTMLCanvasElement> | null = null;
function loadDungeonTiles() {
if (dungeonTileCanvases) return;
dungeonTileCanvases = {};
fetch('/dungeon_tiles.json').then(r => r.json()).then((data: Record<string, string>) => {
Object.entries(data).forEach(([envId, dataUrl]) => {
const img = new Image();
img.onload = () => { dungeonTileCanvases![envId] = processTileImage(img); };
img.src = dataUrl;
});
}).catch(() => {});
}
const RADAR_COLORS: Record<string, string> = {
Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44',
Portal: '#aa44ff', Corpse: '#ff8800', Container: '#cccc44', Door: '#888888',
@ -36,11 +91,12 @@ export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, rad
const mapImgRef = useRef<HTMLImageElement | null>(null);
const objectsRef = useRef<NearbyObject[]>([]);
// Load map image once
// Load map image + dungeon tiles once
useEffect(() => {
const img = new Image();
img.src = '/dereth.png';
img.onload = () => { mapImgRef.current = img; };
loadDungeonTiles();
}, []);
// Send start_radar on open, stop_radar on close
@ -113,8 +169,41 @@ export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, rad
ctx.arc(cx, cy, cx - 1, 0, Math.PI * 2);
ctx.clip();
// Semi-transparent map background (overworld)
if (!isDungeon && mapImgRef.current) {
// Dungeon tile rendering (verbatim from v1 lines 3858-3909)
const landblock = radarData.landblock ?? null;
const playerRawZ = radarData.player_raw_z ?? 0;
if (isDungeon && landblock && (window as any).__dungeonMapCache?.[landblock]) {
const dmap = (window as any).__dungeonMapCache[landblock];
const playerRoundedZ = Math.floor((playerRawZ + 3) / 6) * 6;
ctx.translate(cx, cy);
ctx.rotate(-(heading - 180) * Math.PI / 180);
const cellSize = 10 * scale;
const hasTiles = dungeonTileCanvases && Object.keys(dungeonTileCanvases).length > 0;
const sortedLevels = (dmap.z_levels || []).slice().sort((a: any, b: any) =>
(a.z === playerRoundedZ ? 1 : 0) - (b.z === playerRoundedZ ? 1 : 0));
sortedLevels.forEach((level: any) => {
const isCurrentFloor = level.z === playerRoundedZ;
ctx.globalAlpha = isCurrentFloor ? 0.85 : 0.12;
(level.cells || []).forEach((cell: any) => {
const dx = -(cell.x - playerX) * scale;
const dy = (cell.y - playerY) * scale;
const tileCanvas = hasTiles ? dungeonTileCanvases![String(cell.env_id)] : null;
if (tileCanvas) {
ctx.save();
ctx.translate(dx, dy);
ctx.rotate(cellRotation(cell.rotation));
ctx.drawImage(tileCanvas, -cellSize / 2, -cellSize / 2, cellSize, cellSize);
ctx.restore();
} else {
ctx.fillStyle = isCurrentFloor ? '#3a5a3a' : '#1a2a1a';
ctx.fillRect(dx - cellSize / 2, dy - cellSize / 2, cellSize, cellSize);
}
});
});
ctx.globalAlpha = 1.0;
ctx.setTransform(1, 0, 0, 1, 0, 0);
} else if (!isDungeon && mapImgRef.current) {
// Semi-transparent overworld map background
const mapImg = mapImgRef.current;
const pixPerCoord = mapImg.naturalWidth / 204.2;
const mapCenterX = (playerEW + 102.1) * pixPerCoord;
@ -123,12 +212,8 @@ export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, rad
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(-headingRad);
ctx.drawImage(mapImg,
mapCenterX - (cx / scale) * pixPerCoord,
mapCenterY - (cy / scale) * pixPerCoord,
(size / scale) * pixPerCoord,
(size / scale) * pixPerCoord,
-cx, -cy, size, size);
const srcSize = currentRange * pixPerCoord * 2;
ctx.drawImage(mapImg, mapCenterX - srcSize / 2, mapCenterY - srcSize / 2, srcSize, srcSize, -cx, -cy, size, size);
ctx.restore();
ctx.globalAlpha = 1.0;
}