@@ -4,6 +4,7 @@ import { Map as MapIcon, Flame } from 'lucide-react'
44import { fitView , heatmap , mapBounds , panBy , screenToWorld , worldToScreen , zoomAt } from '@shared/livemap'
55import type { LivePlayer , MapView , Viewport } from '@shared/livemap'
66import { avatarUrl } from '@shared/profile'
7+ import type { StructureMark } from '@shared/regionFormat'
78import type { BridgeStatus } from '@shared/bridgeRelease'
89import { BridgeNotice } from './BridgeNotice'
910
@@ -23,6 +24,16 @@ import { BridgeNotice } from './BridgeNotice'
2324
2425const CELL_CHOICES = [ 16 , 32 , 64 , 128 ]
2526
27+ /** Matches the web map's, so a village is the same dot in all three (#131). */
28+ const MARK_STYLE : Record < string , [ string , string ] > = {
29+ village : [ '#e3b341' , 'V' ] ,
30+ dungeon : [ '#b5504f' , 'D' ] ,
31+ temple : [ '#c58bd6' , 'T' ] ,
32+ fortress : [ '#8b6f4e' , 'F' ] ,
33+ mine : [ '#9aa0a6' , 'M' ] ,
34+ other : [ '#6fa8dc' , '?' ]
35+ }
36+
2637/**
2738 * A chunk tile baked into a 16x16 offscreen canvas, shaded by the step to the
2839 * column north of it. Baking once per chunk rather than per frame is the
@@ -175,6 +186,12 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
175186 // toggle to get the obvious thing.
176187 const [ heads , setHeads ] = useState ( true )
177188 const [ world , setWorld ] = useState ( true )
189+ // Structures, same as the web surfaces have them — off by default, and an
190+ // operator may switch them on without a setting because they can already read
191+ // the world folder (#131).
192+ const [ marks , setMarks ] = useState ( false )
193+ const [ markKind , setMarkKind ] = useState ( '' )
194+ const markStore = useRef ( new Map < string , StructureMark [ ] > ( ) )
178195 const headCache = useRef ( new Map < string , HTMLImageElement | false > ( ) )
179196 const tiles = useRef ( new Map < string , HTMLCanvasElement | null > ( ) )
180197 const tilesPending = useRef ( false )
@@ -205,29 +222,41 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
205222 trimTiles ( tiles . current , visibleChunks ( ) )
206223 tilesPending . current = true
207224 window . msms
208- . mapTiles ( serverId , dim , want )
225+ . mapTiles ( serverId , dim , want , marks )
209226 . then ( ( r ) => {
210227 tilesPending . current = false
211228 for ( const w of want ) {
212229 const k = w . cx + ',' + w . cz
213230 const t = r . tiles [ k ]
214- if ( t ) tiles . current . set ( k , bakeTile ( t ) )
215- else if ( ! r . pending ) tiles . current . set ( k , null )
231+ if ( t ) {
232+ tiles . current . set ( k , bakeTile ( t ) )
233+ if ( t . m ) markStore . current . set ( k , t . m )
234+ } else if ( ! r . pending ) tiles . current . set ( k , null )
216235 }
217236 setTick2 ( ( n ) => n + 1 )
218237 } )
219238 . catch ( ( ) => {
220239 tilesPending . current = false
221240 } )
222- } , [ world , view , vp , dim , serverId , visibleChunks , tick2 ] )
241+ } , [ world , view , vp , dim , serverId , visibleChunks , tick2 , marks ] )
223242
224243 // A different server or dimension is a different world; nothing carries over.
244+ // The nether shares the overworld's coordinates, so keeping tiles across a
245+ // dimension change would draw one world's terrain under another's players.
225246 useEffect ( ( ) => {
226247 tiles . current . clear ( )
248+ markStore . current . clear ( )
227249 setView ( null )
228250 fitFor . current = ''
229251 } , [ serverId , dim ] )
230252
253+ // Tiles already held were fetched without markers, so they carry none.
254+ useEffect ( ( ) => {
255+ if ( ! marks ) return
256+ tiles . current . clear ( )
257+ markStore . current . clear ( )
258+ } , [ marks ] )
259+
231260 useEffect ( ( ) => {
232261 const cv = canvasRef . current
233262 if ( ! cv ) return
@@ -316,6 +345,30 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
316345 }
317346 }
318347
348+ // After the grid and the heatmap, before the players.
349+ if ( marks ) {
350+ g . textAlign = 'center'
351+ g . textBaseline = 'middle'
352+ g . font = `bold ${ 10 * dpr } px Inter, system-ui, sans-serif`
353+ for ( const c of visibleChunks ( ) ) {
354+ for ( const mk of markStore . current . get ( c . cx + ',' + c . cz ) ?? [ ] ) {
355+ if ( markKind && mk . kind !== markKind ) continue
356+ const st = MARK_STYLE [ mk . kind ] ?? MARK_STYLE . other
357+ const x = px ( mk . x )
358+ const y = pz ( mk . z )
359+ g . beginPath ( )
360+ g . arc ( x , y , 7 * dpr , 0 , Math . PI * 2 )
361+ g . fillStyle = st [ 0 ]
362+ g . fill ( )
363+ g . lineWidth = 1.5 * dpr
364+ g . strokeStyle = 'rgba(0,0,0,.6)'
365+ g . stroke ( )
366+ g . fillStyle = '#101014'
367+ g . fillText ( st [ 1 ] , x , y + 0.5 * dpr )
368+ }
369+ }
370+ }
371+
319372 g . font = `${ 11 * dpr } px Inter, system-ui, sans-serif`
320373 g . textAlign = 'center'
321374 g . textBaseline = 'bottom'
@@ -341,7 +394,7 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
341394 g . fillStyle = 'rgba(255,255,255,.92)'
342395 g . fillText ( p . name , x , y - ( head ? 12 : 7 ) * dpr )
343396 }
344- } , [ shown , bounds , heat , cell , showHeat , view , vp , dim , heads , world , tick2 , visibleChunks ] )
397+ } , [ shown , bounds , heat , cell , showHeat , view , vp , dim , heads , world , marks , markKind , tick2 , visibleChunks ] )
345398
346399 const localPoint = ( e : React . MouseEvent ) : { x : number ; y : number } => {
347400 const r = ( e . target as HTMLCanvasElement ) . getBoundingClientRect ( )
@@ -401,6 +454,19 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
401454 < button className = { `btn sm ${ world ? 'primary' : '' } ` } onClick = { ( ) => setWorld ( ( v ) => ! v ) } >
402455 { t ( 'map.world' ) }
403456 </ button >
457+ < button className = { `btn sm ${ marks ? 'primary' : '' } ` } onClick = { ( ) => setMarks ( ( v ) => ! v ) } >
458+ { t ( 'map.structures' ) }
459+ </ button >
460+ { marks && (
461+ < select className = "select" style = { { width : 150 } } value = { markKind } onChange = { ( e ) => setMarkKind ( e . target . value ) } >
462+ < option value = "" > { t ( 'map.allStructures' ) } </ option >
463+ { ( [ 'village' , 'dungeon' , 'temple' , 'fortress' , 'mine' ] as const ) . map ( ( k ) => (
464+ < option key = { k } value = { k } >
465+ { t ( 'map.structure_' + k ) }
466+ </ option >
467+ ) ) }
468+ </ select >
469+ ) }
404470 < button className = "btn sm" onClick = { ( ) => setView ( null ) } >
405471 { t ( 'map.resetView' ) }
406472 </ button >
0 commit comments