import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { buildWorldTerrainContext, proceduralTerrain, townsApiToInfluences } from '../game/procedural'; import type { Town } from '../game/types'; import { useT } from '../i18n'; // ---- Types ---- interface MinimapProps { heroX: number; heroY: number; towns: Town[]; routeWaypoints: Array<{ x: number; y: number }> | null; } /** 0 = свернуто, 1 = маленькая, 2 = большая */ type MapMode = 0 | 1 | 2; // ---- Constants ---- const SIZE_SMALL = 120; const SIZE_LARGE = 240; /** Each pixel represents this many world units */ const WORLD_UNITS_PER_PX = 10; /** Only redraw when hero has moved at least this many world units */ const REDRAW_THRESHOLD = 5; const TERRAIN_BG: Record = { grass: '#3a7a28', road: '#8e7550', dirt: '#7c6242', stone: '#6c7078', plaza: '#6c6c75', forest_floor: '#2d5a24', ruins_floor: '#5a5a58', canyon_floor: '#8b7355', swamp_floor: '#3d5c42', volcanic_floor: '#5c3830', astral_floor: '#4a4580', }; const DEFAULT_BG = '#1e2420'; function hexToRgb(hex: string): [number, number, number] { const s = hex.replace('#', ''); return [ parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16), ]; } const TERRAIN_RGB = new Map( Object.entries(TERRAIN_BG).map(([k, v]) => [k, hexToRgb(v)]), ); const DEFAULT_RGB = hexToRgb(DEFAULT_BG); // ---- Styles ---- const containerStyle: CSSProperties = { position: 'fixed', top: 12, right: 8, zIndex: 40, userSelect: 'none', }; const toggleBtnStyle: CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '3px 8px', fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.55)', background: 'rgba(0,0,0,0.5)', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 6, cursor: 'pointer', fontFamily: 'monospace', letterSpacing: 1, WebkitTapHighlightColor: 'transparent', marginBottom: 2, marginLeft: 'auto', pointerEvents: 'auto', }; function canvasStyleForSize(px: number): CSSProperties { return { width: px, height: px, borderRadius: 8, border: '2px solid rgba(255,255,255,0.2)', boxShadow: '0 2px 12px rgba(0,0,0,0.6)', display: 'block', }; } function modeLabel(mode: MapMode, mapStr: string): string { if (mode === 0) return `${mapStr} \u25B6`; if (mode === 1) return `${mapStr} S`; return `${mapStr} L`; } // ---- Component ---- export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) { const tr = useT(); const [mode, setMode] = useState(1); const canvasRef = useRef(null); const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN }); const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null); const lastRouteKey = useRef(''); const lastTownsKey = useRef(''); const size = mode === 2 ? SIZE_LARGE : SIZE_SMALL; const collapsed = mode === 0; useEffect(() => { if (!collapsed) { lastDrawPos.current = { x: NaN, y: NaN }; lastHeroTile.current = null; lastRouteKey.current = ''; lastTownsKey.current = ''; } }, [collapsed, size]); useEffect(() => { if (collapsed) return; const routeKey = routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints.map((p) => `${p.x},${p.y}`).join(';') : ''; const routeChanged = routeKey !== lastRouteKey.current; const townsKey = towns.length === 0 ? '' : towns.map((t) => `${t.id}:${t.worldX}:${t.worldY}`).join(';'); const townsChanged = townsKey !== lastTownsKey.current; const tileX = Math.floor(heroX); const tileY = Math.floor(heroY); const last = lastDrawPos.current; const dx = Math.abs(heroX - last.x); const dy = Math.abs(heroY - last.y); const lt = lastHeroTile.current; const tileChanged = !lt || lt.tx !== tileX || lt.ty !== tileY; if ( !routeChanged && !townsChanged && !tileChanged && dx < REDRAW_THRESHOLD && dy < REDRAW_THRESHOLD ) { return; } lastRouteKey.current = routeKey; lastTownsKey.current = townsKey; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; lastDrawPos.current = { x: heroX, y: heroY }; lastHeroTile.current = { tx: tileX, ty: tileY }; const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const minDim = Math.min(w, h); const gridStep = Math.max(10, Math.round(minDim / 6)); const fontLetter = `${Math.max(7, Math.round(minDim * 0.067))}px monospace`; const fontTown = `${Math.max(5, Math.round(minDim * 0.048))}px monospace`; const margin = Math.max(4, Math.round(minDim * 0.05)); const heroR = Math.max(3, Math.round(minDim * 0.035)); const glowR = Math.max(8, Math.round(minDim * 0.09)); const activeRoute = routeWaypoints && routeWaypoints.length >= 2 ? routeWaypoints : null; const miniCtx = towns.length === 0 ? null : buildWorldTerrainContext(townsApiToInfluences(towns), activeRoute); if (!miniCtx || miniCtx.towns.length === 0) { const terrain = proceduralTerrain(tileX, tileY, null); ctx.fillStyle = TERRAIN_BG[terrain] ?? DEFAULT_BG; ctx.fillRect(0, 0, w, h); } else { const img = ctx.createImageData(w, h); const data = img.data; let q = 0; const scale = WORLD_UNITS_PER_PX; for (let py = 0; py < h; py++) { const wy = Math.floor(heroY + (py - cy) * scale); for (let px = 0; px < w; px++) { const wx = Math.floor(heroX + (px - cx) * scale); const terrain = proceduralTerrain(wx, wy, miniCtx); const rgb = TERRAIN_RGB.get(terrain) ?? DEFAULT_RGB; data[q++] = rgb[0]; data[q++] = rgb[1]; data[q++] = rgb[2]; data[q++] = 255; } } ctx.putImageData(img, 0, 0); } ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.lineWidth = 0.5; for (let gx = 0; gx < w; gx += gridStep) { ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke(); } for (let gy = 0; gy < h; gy += gridStep) { ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke(); } for (const town of towns) { const relX = (town.worldX - heroX) / WORLD_UNITS_PER_PX; const relY = (town.worldY - heroY) / WORLD_UNITS_PER_PX; let px = cx + relX; let py = cy + relY; const clamped = px < margin || px > w - margin || py < margin || py > h - margin; px = Math.max(margin, Math.min(w - margin, px)); py = Math.max(margin, Math.min(h - margin, py)); const radius = clamped ? heroR * 0.95 : heroR + 1; ctx.beginPath(); ctx.arc(px, py, radius, 0, Math.PI * 2); ctx.fillStyle = '#daa520'; ctx.fill(); ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1.2; ctx.stroke(); const letter = town.name.charAt(0).toUpperCase(); ctx.fillStyle = '#000'; ctx.font = `bold ${fontLetter}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(letter, px, py + 0.5); if (!clamped) { ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = fontTown; ctx.fillText(town.name, px, py + radius + Math.max(5, minDim * 0.04)); } } const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR); grad.addColorStop(0, 'rgba(0, 255, 255, 0.3)'); grad.addColorStop(1, 'rgba(0, 255, 255, 0)'); ctx.fillStyle = grad; ctx.fillRect(cx - glowR, cy - glowR, glowR * 2, glowR * 2); ctx.beginPath(); ctx.arc(cx, cy, heroR, 0, Math.PI * 2); ctx.fillStyle = '#00ffff'; ctx.fill(); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; ctx.stroke(); }, [heroX, heroY, towns, routeWaypoints, collapsed, size]); return (
{!collapsed && ( )}
); }