@ -1,4 +1,4 @@
import { useEffect , use Ref, useState , type CSSProperties } from 'react' ;
import { useEffect , use Memo, use Ref, useState , type CSSProperties } from 'react' ;
import { buildWorldTerrainContext , proceduralTerrain , townsApiToInfluences } from '../game/procedural' ;
import { buildWorldTerrainContext , proceduralTerrain , townsApiToInfluences } from '../game/procedural' ;
import type { Town } from '../game/types' ;
import type { Town } from '../game/types' ;
import { useT } from '../i18n' ;
import { useT } from '../i18n' ;
@ -15,15 +15,30 @@ interface MinimapProps {
/** 0 = свернуто, 1 = маленькая, 2 = большая */
/** 0 = свернуто, 1 = маленькая, 2 = большая */
type MapMode = 0 | 1 | 2 ;
type MapMode = 0 | 1 | 2 ;
/** Cached terrain layer (viewport-sized); rebuilt only when view in world space meaningfully shifts */
type TerrainSliceCache = {
w : number ;
h : number ;
centerX : number ;
centerY : number ;
worldKey : string ;
/** Offscreen terrain only (no UI) */
canvas : HTMLCanvasElement ;
} ;
// ---- Constants ----
// ---- Constants ----
const SIZE_SMALL = 120 ;
const SIZE_SMALL = 120 ;
const SIZE_LARGE = 240 ;
const SIZE_LARGE = 240 ;
/** Each pixel represents this many world units */
/** World units per minimap pixel (10× coarser than legacy 10) */
const WORLD_UNITS_PER_PX = 10 ;
const WORLD_UNITS_PER_PX = 100 ;
/** Only redraw when hero has moved at least this many world units */
const REDRAW_THRESHOLD = 5 ;
/ * *
* Repaint terrain when hero moved this many world units since last bake .
* ~ 0.5 minimap pixel so sub - pixel pan stays cheap ; full terrain bake is the hotspot .
* /
const TERRAIN_REBAKE_THRESHOLD = WORLD_UNITS_PER_PX * 0.45 ;
const TERRAIN_BG : Record < string , string > = {
const TERRAIN_BG : Record < string , string > = {
grass : '#3a7a28' ,
grass : '#3a7a28' ,
@ -103,73 +118,163 @@ function modeLabel(mode: MapMode, mapStr: string): string {
return ` ${ mapStr } L ` ;
return ` ${ mapStr } L ` ;
}
}
function bakeTerrainSlice (
w : number ,
h : number ,
centerX : number ,
centerY : number ,
miniCtx : ReturnType < typeof buildWorldTerrainContext > | null ,
) : HTMLCanvasElement {
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = w ;
canvas . height = h ;
const ctx = canvas . getContext ( '2d' ) ;
if ( ! ctx ) return canvas ;
const cx = w / 2 ;
const cy = h / 2 ;
const scale = WORLD_UNITS_PER_PX ;
if ( ! miniCtx || miniCtx . towns . length === 0 ) {
const terrain = proceduralTerrain ( Math . floor ( centerX ) , Math . floor ( centerY ) , null ) ;
ctx . fillStyle = TERRAIN_BG [ terrain ] ? ? DEFAULT_BG ;
ctx . fillRect ( 0 , 0 , w , h ) ;
return canvas ;
}
const img = ctx . createImageData ( w , h ) ;
const data = img . data ;
let q = 0 ;
for ( let py = 0 ; py < h ; py ++ ) {
const wy = Math . floor ( centerY + ( py - cy ) * scale ) ;
for ( let px = 0 ; px < w ; px ++ ) {
const wx = Math . floor ( centerX + ( 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 ) ;
return canvas ;
}
function terrainCacheNeedsRebake (
cache : TerrainSliceCache | null ,
w : number ,
h : number ,
heroX : number ,
heroY : number ,
worldKey : string ,
) : boolean {
if ( ! cache ) return true ;
if ( cache . w !== w || cache . h !== h ) return true ;
if ( cache . worldKey !== worldKey ) return true ;
if (
Math . abs ( heroX - cache . centerX ) >= TERRAIN_REBAKE_THRESHOLD ||
Math . abs ( heroY - cache . centerY ) >= TERRAIN_REBAKE_THRESHOLD
) {
return true ;
}
return false ;
}
/** Sample baked terrain while scrolling so the hero stays centered between rebakes */
function drawTerrainFromCache (
ctx : CanvasRenderingContext2D ,
cache : TerrainSliceCache ,
heroX : number ,
heroY : number ,
dw : number ,
dh : number ,
) : void {
const u = WORLD_UNITS_PER_PX ;
const { centerX : tcx , centerY : tcy , canvas : off } = cache ;
const ow = off . width ;
const oh = off . height ;
ctx . fillStyle = DEFAULT_BG ;
ctx . fillRect ( 0 , 0 , dw , dh ) ;
const sx = ( heroX - tcx ) / u ;
const sy = ( heroY - tcy ) / u ;
const srcX0 = Math . max ( 0 , Math . floor ( sx ) ) ;
const srcY0 = Math . max ( 0 , Math . floor ( sy ) ) ;
const srcX1 = Math . min ( ow , Math . ceil ( sx + dw ) ) ;
const srcY1 = Math . min ( oh , Math . ceil ( sy + dh ) ) ;
if ( srcX0 < srcX1 && srcY0 < srcY1 ) {
const destX = srcX0 - sx ;
const destY = srcY0 - sy ;
const iw = srcX1 - srcX0 ;
const ih = srcY1 - srcY0 ;
ctx . drawImage ( off , srcX0 , srcY0 , iw , ih , destX , destY , iw , ih ) ;
}
}
// ---- Component ----
// ---- Component ----
export function Minimap ( { heroX , heroY , towns , routeWaypoints } : MinimapProps ) {
export function Minimap ( { heroX , heroY , towns , routeWaypoints } : MinimapProps ) {
const tr = useT ( ) ;
const tr = useT ( ) ;
const [ mode , setMode ] = useState < MapMode > ( 1 ) ;
const [ mode , setMode ] = useState < MapMode > ( 1 ) ;
const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
const lastDrawPos = useRef < { x : number ; y : number } > ( { x : NaN , y : NaN } ) ;
const terrainCacheRef = useRef < TerrainSliceCache | null > ( null ) ;
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 size = mode === 2 ? SIZE_LARGE : SIZE_SMALL ;
const collapsed = mode === 0 ;
const collapsed = mode === 0 ;
useEffect ( ( ) = > {
const routeKey = useMemo (
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 && routeWaypoints . length >= 2
? routeWaypoints . map ( ( p ) = > ` ${ p . x } , ${ p . y } ` ) . join ( ';' )
? routeWaypoints . map ( ( p ) = > ` ${ p . x } , ${ p . y } ` ) . join ( ';' )
: '' ;
: '' ,
const routeChanged = routeKey !== lastRouteKey . current ;
[ routeWaypoints ] ,
) ;
const townsKey =
const townsKey = useMemo (
( ) = >
towns . length === 0
towns . length === 0
? ''
? ''
: towns . map ( ( t ) = > ` ${ t . id } : ${ t . worldX } : ${ t . worldY } ` ) . join ( ';' ) ;
: towns . map ( ( t ) = > ` ${ t . id } : ${ t . worldX } : ${ t . worldY } : ${ t . radius } : ${ t . biome } : ${ t . levelMin } ` ) . join ( '|' ) ,
const townsChanged = townsKey !== lastTownsKey . current ;
[ towns ] ,
) ;
const tileX = Math . floor ( heroX ) ;
const tileY = Math . floor ( heroY ) ;
const worldKey = ` ${ townsKey } # ${ routeKey } ` ;
const last = lastDrawPos . current ;
const dx = Math . abs ( heroX - last . x ) ;
const miniCtx = useMemo ( ( ) = > {
const dy = Math . abs ( heroY - last . y ) ;
if ( towns . length === 0 ) return null ;
const lt = lastHeroTile . current ;
const activeRoute =
const tileChanged = ! lt || lt . tx !== tileX || lt . ty !== tileY ;
routeWaypoints && routeWaypoints . length >= 2 ? routeWaypoints : null ;
if (
return buildWorldTerrainContext ( townsApiToInfluences ( towns ) , activeRoute ) ;
! routeChanged &&
} , [ worldKey , towns , routeWaypoints ] ) ;
! townsChanged &&
! tileChanged &&
useEffect ( ( ) = > {
dx < REDRAW_THRESHOLD &&
if ( collapsed ) return ;
dy < REDRAW_THRESHOLD
) {
return ;
}
lastRouteKey . current = routeKey ;
lastTownsKey . current = townsKey ;
const canvas = canvasRef . current ;
const canvas = canvasRef . current ;
if ( ! canvas ) return ;
if ( ! canvas ) return ;
const ctx = canvas . getContext ( '2d' ) ;
const ctx = canvas . getContext ( '2d' ) ;
if ( ! ctx ) return ;
if ( ! ctx ) return ;
lastDrawPos . current = { x : heroX , y : heroY } ;
lastHeroTile . current = { tx : tileX , ty : tileY } ;
const w = canvas . width ;
const w = canvas . width ;
const h = canvas . height ;
const h = canvas . height ;
let cache = terrainCacheRef . current ;
if ( terrainCacheNeedsRebake ( cache , w , h , heroX , heroY , worldKey ) ) {
cache = {
w ,
h ,
centerX : heroX ,
centerY : heroY ,
worldKey ,
canvas : bakeTerrainSlice ( w , h , heroX , heroY , miniCtx ) ,
} ;
terrainCacheRef . current = cache ;
}
drawTerrainFromCache ( ctx , cache ! , heroX , heroY , w , h ) ;
const cx = w / 2 ;
const cx = w / 2 ;
const cy = h / 2 ;
const cy = h / 2 ;
const minDim = Math . min ( w , h ) ;
const minDim = Math . min ( w , h ) ;
@ -180,51 +285,26 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
const heroR = Math . max ( 3 , Math . round ( minDim * 0.035 ) ) ;
const heroR = Math . max ( 3 , Math . round ( minDim * 0.035 ) ) ;
const glowR = Math . max ( 8 , Math . round ( minDim * 0.09 ) ) ;
const glowR = Math . max ( 8 , Math . round ( minDim * 0.09 ) ) ;
const activeRoute =
const unitsPerPx = WORLD_UNITS_PER_PX ;
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 . strokeStyle = 'rgba(255, 255, 255, 0.06)' ;
ctx . lineWidth = 0.5 ;
ctx . lineWidth = 0.5 ;
for ( let gx = 0 ; gx < w ; gx += gridStep ) {
for ( let gx = 0 ; gx < w ; gx += gridStep ) {
ctx . beginPath ( ) ; ctx . moveTo ( gx , 0 ) ; ctx . lineTo ( gx , h ) ; ctx . stroke ( ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( gx , 0 ) ;
ctx . lineTo ( gx , h ) ;
ctx . stroke ( ) ;
}
}
for ( let gy = 0 ; gy < h ; gy += gridStep ) {
for ( let gy = 0 ; gy < h ; gy += gridStep ) {
ctx . beginPath ( ) ; ctx . moveTo ( 0 , gy ) ; ctx . lineTo ( w , gy ) ; ctx . stroke ( ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( 0 , gy ) ;
ctx . lineTo ( w , gy ) ;
ctx . stroke ( ) ;
}
}
for ( const town of towns ) {
for ( const town of towns ) {
const relX = ( town . worldX - heroX ) / WORLD_UNITS_PER_PX ;
let px = cx + ( town . worldX - heroX ) / unitsPerPx ;
const relY = ( town . worldY - heroY ) / WORLD_UNITS_PER_PX ;
let py = cy + ( town . worldY - heroY ) / unitsPerPx ;
let px = cx + relX ;
let py = cy + relY ;
const clamped =
const clamped =
px < margin || px > w - margin || py < margin || py > h - margin ;
px < margin || px > w - margin || py < margin || py > h - margin ;
@ -267,7 +347,7 @@ export function Minimap({ heroX, heroY, towns, routeWaypoints }: MinimapProps) {
ctx . strokeStyle = '#ffffff' ;
ctx . strokeStyle = '#ffffff' ;
ctx . lineWidth = 1.5 ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
ctx . stroke ( ) ;
} , [ heroX , heroY , towns , routeWaypoints, collapsed , size ] ) ;
} , [ heroX , heroY , towns , collapsed, size , worldKey , miniCtx ] ) ;
return (
return (
< div style = { containerStyle } >
< div style = { containerStyle } >