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:
parent
76baec33e7
commit
a59296867d
13 changed files with 219 additions and 71 deletions
|
|
@ -62,7 +62,7 @@ export const RareNotification: React.FC<Props> = ({ recentRares }) => {
|
||||||
<div className="ml-rare-notifications">
|
<div className="ml-rare-notifications">
|
||||||
{active.map(n => (
|
{active.map(n => (
|
||||||
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
|
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
|
||||||
<div className="ml-rare-notif-title">LEGENDARY RARE!</div>
|
<div className="ml-rare-notif-title">🎆 LEGENDARY RARE! 🎆</div>
|
||||||
<div className="ml-rare-notif-name">{n.rareName}</div>
|
<div className="ml-rare-notif-name">{n.rareName}</div>
|
||||||
<div className="ml-rare-notif-by">found by</div>
|
<div className="ml-rare-notif-by">found by</div>
|
||||||
<div className="ml-rare-notif-char">{n.charName}</div>
|
<div className="ml-rare-notif-char">{n.charName}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useCallback, useState, useMemo } from 'react';
|
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
||||||
|
import { apiFetch } from '../../api/client';
|
||||||
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
|
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
|
||||||
import { MapView } from './MapView';
|
import { MapView } from './MapView';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
@ -16,8 +17,8 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
||||||
const getColor = usePlayerColors();
|
const getColor = usePlayerColors();
|
||||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
const [showPortals, setShowPortals] = useState(false);
|
const [showPortals, setShowPortals] = useState(false);
|
||||||
|
const [selectedPlayer, setSelectedPlayer] = useState<string | null>(null);
|
||||||
|
|
||||||
// Memoize derived data to prevent child re-renders when characters Map ref changes but content is same
|
|
||||||
const players = useMemo(() =>
|
const players = useMemo(() =>
|
||||||
Array.from(data.characters.values()).filter(c => c.telemetry).map(c => c.telemetry!),
|
Array.from(data.characters.values()).filter(c => c.telemetry).map(c => c.telemetry!),
|
||||||
[data.characters]);
|
[data.characters]);
|
||||||
|
|
@ -26,7 +27,14 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
||||||
new Map(Array.from(data.characters.values()).filter(c => c.vitals).map(c => [c.name, c.vitals!])),
|
new Map(Array.from(data.characters.values()).filter(c => c.vitals).map(c => [c.name, c.vitals!])),
|
||||||
[data.characters]);
|
[data.characters]);
|
||||||
|
|
||||||
const handleSelectPlayer = useCallback((_name: string) => {}, []);
|
const [version, setVersion] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ version: string }>('/api-version').then(d => setVersion(d.version ?? '')).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectPlayer = useCallback((name: string) => {
|
||||||
|
setSelectedPlayer(prev => prev === name ? null : name);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WindowManagerProvider>
|
<WindowManagerProvider>
|
||||||
|
|
@ -51,10 +59,12 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
||||||
onSelectPlayer={handleSelectPlayer}
|
onSelectPlayer={handleSelectPlayer}
|
||||||
showHeatmap={showHeatmap}
|
showHeatmap={showHeatmap}
|
||||||
showPortals={showPortals}
|
showPortals={showPortals}
|
||||||
|
selectedPlayer={selectedPlayer}
|
||||||
/>
|
/>
|
||||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
|
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages}
|
||||||
nearbyObjects={data.nearbyObjects} socket={data.socketRef.current} />
|
nearbyObjects={data.nearbyObjects} socket={data.socketRef.current} />
|
||||||
<RareNotification recentRares={data.recentRares} />
|
<RareNotification recentRares={data.recentRares} />
|
||||||
|
{version && <div className="ml-version">v{version}</div>}
|
||||||
</div>
|
</div>
|
||||||
</WindowManagerProvider>
|
</WindowManagerProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { pxToWorld, formatCoord } from '../../utils/coordinates';
|
import { worldToPx, pxToWorld, formatCoord } from '../../utils/coordinates';
|
||||||
import { PlayerDots } from './PlayerDots';
|
import { PlayerDots } from './PlayerDots';
|
||||||
import { TrailsSVG } from './TrailsSVG';
|
import { TrailsSVG } from './TrailsSVG';
|
||||||
import { HeatmapCanvas } from './HeatmapCanvas';
|
import { HeatmapCanvas } from './HeatmapCanvas';
|
||||||
|
|
@ -12,13 +12,14 @@ interface Props {
|
||||||
onSelectPlayer: (name: string) => void;
|
onSelectPlayer: (name: string) => void;
|
||||||
showHeatmap: boolean;
|
showHeatmap: boolean;
|
||||||
showPortals: boolean;
|
showPortals: boolean;
|
||||||
|
selectedPlayer: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ZOOM = 20;
|
const MAX_ZOOM = 20;
|
||||||
const MIN_ZOOM = 0.3;
|
const MIN_ZOOM = 0.3;
|
||||||
|
|
||||||
// Pan/zoom via direct DOM manipulation — bypasses React state entirely for smooth 60fps
|
// Pan/zoom via direct DOM manipulation — bypasses React state entirely for smooth 60fps
|
||||||
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, showHeatmap, showPortals }) => {
|
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, showHeatmap, showPortals, selectedPlayer }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const groupRef = useRef<HTMLDivElement>(null);
|
const groupRef = useRef<HTMLDivElement>(null);
|
||||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
|
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
|
||||||
|
|
@ -96,6 +97,22 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, sh
|
||||||
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
|
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
|
||||||
}, [applyTransform, imgSize.w, imgSize.h]);
|
}, [applyTransform, imgSize.w, imgSize.h]);
|
||||||
|
|
||||||
|
// Zoom to selected player
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPlayer || imgSize.w === 0 || !containerRef.current) return;
|
||||||
|
const player = players.find(p => p.character_name === selectedPlayer);
|
||||||
|
if (!player) return;
|
||||||
|
const { x, y } = worldToPx(player.ew, player.ns, imgSize.w, imgSize.h);
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const focusZoom = 3;
|
||||||
|
txRef.current = {
|
||||||
|
scale: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, focusZoom)),
|
||||||
|
offX: rect.width / 2 - x * focusZoom,
|
||||||
|
offY: rect.height / 2 - y * focusZoom,
|
||||||
|
};
|
||||||
|
applyTransform();
|
||||||
|
}, [selectedPlayer, players, imgSize.w, imgSize.h, applyTransform]);
|
||||||
|
|
||||||
const handleDotHover = useCallback((player: TelemetrySnapshot | null, x: number, y: number) => {
|
const handleDotHover = useCallback((player: TelemetrySnapshot | null, x: number, y: number) => {
|
||||||
setTooltip(player ? { x, y, player } : null);
|
setTooltip(player ? { x, y, player } : null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -115,6 +132,7 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, sh
|
||||||
getColor={getColor}
|
getColor={getColor}
|
||||||
onHover={handleDotHover}
|
onHover={handleDotHover}
|
||||||
onSelect={onSelectPlayer}
|
onSelect={onSelectPlayer}
|
||||||
|
selectedPlayer={selectedPlayer}
|
||||||
/>
|
/>
|
||||||
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
|
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ interface Props {
|
||||||
getColor: (name: string) => string;
|
getColor: (name: string) => string;
|
||||||
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
|
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
|
||||||
onSelect: (name: string) => void;
|
onSelect: (name: string) => void;
|
||||||
|
selectedPlayer: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect }) => {
|
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect, selectedPlayer }) => {
|
||||||
const dots = useMemo(() =>
|
const dots = useMemo(() =>
|
||||||
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
|
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
|
||||||
...p,
|
...p,
|
||||||
|
|
@ -25,7 +26,7 @@ export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, ge
|
||||||
{dots.map(d => (
|
{dots.map(d => (
|
||||||
<div
|
<div
|
||||||
key={d.character_name}
|
key={d.character_name}
|
||||||
className="ml-dot"
|
className={`ml-dot ${selectedPlayer === d.character_name ? 'ml-dot-selected' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
left: d.pos.x,
|
left: d.pos.x,
|
||||||
top: d.pos.y,
|
top: d.pos.y,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export const SidebarWindowButtons: React.FC = () => {
|
||||||
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
|
onClick={() => openWindow('issues', 'Issues Board')}>📋 Issues</span>
|
||||||
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||||
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
|
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
|
||||||
|
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openWindow('combatpicker', 'Combat Stats')}>⚔️ Combat</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,61 @@ import { DraggableWindow } from './DraggableWindow';
|
||||||
const CANVAS_SIZE = 300;
|
const CANVAS_SIZE = 300;
|
||||||
const DEFAULT_RANGE = 0.5; // AC units, ~120m
|
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> = {
|
const RADAR_COLORS: Record<string, string> = {
|
||||||
Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44',
|
Monster: '#ff4444', Player: '#4488ff', NPC: '#44cc44', Vendor: '#44cc44',
|
||||||
Portal: '#aa44ff', Corpse: '#ff8800', Container: '#cccc44', Door: '#888888',
|
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 mapImgRef = useRef<HTMLImageElement | null>(null);
|
||||||
const objectsRef = useRef<NearbyObject[]>([]);
|
const objectsRef = useRef<NearbyObject[]>([]);
|
||||||
|
|
||||||
// Load map image once
|
// Load map image + dungeon tiles once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = '/dereth.png';
|
img.src = '/dereth.png';
|
||||||
img.onload = () => { mapImgRef.current = img; };
|
img.onload = () => { mapImgRef.current = img; };
|
||||||
|
loadDungeonTiles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Send start_radar on open, stop_radar on close
|
// 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.arc(cx, cy, cx - 1, 0, Math.PI * 2);
|
||||||
ctx.clip();
|
ctx.clip();
|
||||||
|
|
||||||
// Semi-transparent map background (overworld)
|
// Dungeon tile rendering (verbatim from v1 lines 3858-3909)
|
||||||
if (!isDungeon && mapImgRef.current) {
|
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 mapImg = mapImgRef.current;
|
||||||
const pixPerCoord = mapImg.naturalWidth / 204.2;
|
const pixPerCoord = mapImg.naturalWidth / 204.2;
|
||||||
const mapCenterX = (playerEW + 102.1) * pixPerCoord;
|
const mapCenterX = (playerEW + 102.1) * pixPerCoord;
|
||||||
|
|
@ -123,12 +212,8 @@ export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, rad
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(cx, cy);
|
ctx.translate(cx, cy);
|
||||||
ctx.rotate(-headingRad);
|
ctx.rotate(-headingRad);
|
||||||
ctx.drawImage(mapImg,
|
const srcSize = currentRange * pixPerCoord * 2;
|
||||||
mapCenterX - (cx / scale) * pixPerCoord,
|
ctx.drawImage(mapImg, mapCenterX - srcSize / 2, mapCenterY - srcSize / 2, srcSize, srcSize, -cx, -cy, size, size);
|
||||||
mapCenterY - (cy / scale) * pixPerCoord,
|
|
||||||
(size / scale) * pixPerCoord,
|
|
||||||
(size / scale) * pixPerCoord,
|
|
||||||
-cx, -cy, size, size);
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,27 @@ export function useLiveData(): DashboardState {
|
||||||
} else if (msg.type === 'rare') {
|
} else if (msg.type === 'rare') {
|
||||||
const r = msg as RareMessage;
|
const r = msg as RareMessage;
|
||||||
setRecentRares(prev => [r, ...prev].slice(0, 50));
|
setRecentRares(prev => [r, ...prev].slice(0, 50));
|
||||||
|
} else if (msg.type === 'dungeon_map') {
|
||||||
|
// Cache dungeon map data for radar rendering (stored on window for canvas access)
|
||||||
|
const dm = msg as unknown as { landblock: string; z_levels: any[] };
|
||||||
|
if (dm.landblock) {
|
||||||
|
if (!(window as any).__dungeonMapCache) (window as any).__dungeonMapCache = {};
|
||||||
|
(window as any).__dungeonMapCache[dm.landblock] = dm;
|
||||||
|
}
|
||||||
} else if (msg.type === 'nearby_objects') {
|
} else if (msg.type === 'nearby_objects') {
|
||||||
const no = msg as unknown as { character_name: string; objects: any[] };
|
const no = msg as unknown as { character_name: string; objects: any[]; is_dungeon?: boolean; landblock?: number };
|
||||||
setNearbyObjects(prev => {
|
setNearbyObjects(prev => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(no.character_name, no);
|
next.set(no.character_name, no);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Request dungeon map if in dungeon and not cached
|
||||||
|
if (no.is_dungeon && no.landblock && !(window as any).__dungeonMapCache?.[no.landblock]) {
|
||||||
|
const ws = socketRef.current;
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'request_dungeon_map', landblock: no.landblock }));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (msg.type === 'chat') {
|
} else if (msg.type === 'chat') {
|
||||||
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
|
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
|
||||||
setChatMessages(prev => {
|
setChatMessages(prev => {
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,24 @@
|
||||||
height: 10px;
|
height: 10px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
.ml-dot.ml-dot-selected {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
animation: ml-blink 0.6s step-end infinite;
|
||||||
|
}
|
||||||
|
@keyframes ml-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── Version display ──────────────────────────────────── */
|
||||||
|
.ml-version {
|
||||||
|
position: fixed;
|
||||||
|
top: 6px;
|
||||||
|
left: 410px;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #555;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Tooltip ──────────────────────────────────────────── */
|
/* ── Tooltip ──────────────────────────────────────────── */
|
||||||
.ml-tooltip {
|
.ml-tooltip {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
49
static/v2/assets/index-Cl1VRDup.js
Normal file
49
static/v2/assets/index-Cl1VRDup.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Mosswart Overlord v2</title>
|
<title>Mosswart Overlord v2</title>
|
||||||
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||||
<script type="module" crossorigin src="/v2/assets/index-Bn2ev6gG.js"></script>
|
<script type="module" crossorigin src="/v2/assets/index-Cl1VRDup.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-t3L0RG9a.css">
|
<link rel="stylesheet" crossorigin href="/v2/assets/index-LcLAhCFL.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue