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
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>
|
|
);
|
|
}
|