You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

293 lines
8.4 KiB
TypeScript

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<string, string> = {
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<string, [number, number, number]>(
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<MapMode>(1);
const canvasRef = useRef<HTMLCanvasElement>(null);
const lastDrawPos = useRef<{ x: number; y: number }>({ x: NaN, y: NaN });
const lastHeroTile = useRef<{ tx: number; ty: number } | null>(null);
const lastRouteKey = useRef<string>('');
const lastTownsKey = useRef<string>('');
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 (
<div style={containerStyle}>
<button
type="button"
style={toggleBtnStyle}
title="Карта: свернуть / малый / большой"
onClick={() => setMode((m) => ((((m + 1) % 3) as MapMode)))}
>
{modeLabel(mode, tr.map)}
</button>
{!collapsed && (
<canvas
ref={canvasRef}
width={size}
height={size}
style={canvasStyleForSize(size)}
/>
)}
</div>
);
}