Skip to content

Commit 70b8c47

Browse files
committed
Self-review: markers on the desktop too, and drop tiles when the dimension changes
I split the three maps again, one PR after merging them. Structures went into the shared web engine and the routes, and the desktop — which is an admin surface and the one an operator uses most — got none of it. It has the toggle, the filter and the same markers now, drawn from the same `structureKind` grouping so a village is the same dot everywhere. Worse, and older than this PR: tiles are keyed by chunk alone, and the nether uses the same coordinates as the overworld. Switching dimension kept the tiles, so one world's terrain was drawn under another world's players — shipped in #119 and true on every surface. Both caches are dropped when the dimension changes. The markers were also drawn before the grid and the heatmap, so a grid line crossed every one of them. They go after both and before the players: a marker under a line reads as a smudge, and a player must never be behind one.
1 parent e34e397 commit 70b8c47

7 files changed

Lines changed: 111 additions & 13 deletions

File tree

src/main/ipc/register.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,17 @@ export function registerIpc(): void {
302302
H(IPC.bridgeInstall, (_e, id: string) =>
303303
bridgeInstall.installBridge(id, { by: 'desktop', source: 'panel' })
304304
)
305-
H(IPC.mapTiles, (_e, id: string, dim: string, chunks: { cx: number; cz: number }[]) =>
306-
// Capped here too: the renderer is trusted, but a bug there should not be
307-
// able to queue a whole world any more than a web caller can.
308-
worldTiles.requestTiles(id, dim, (chunks ?? []).slice(0, worldTiles.MAX_TILES_PER_REQUEST))
305+
H(
306+
IPC.mapTiles,
307+
(_e, id: string, dim: string, chunks: { cx: number; cz: number }[], marks?: boolean) =>
308+
// Capped here too: the renderer is trusted, but a bug there should not be
309+
// able to queue a whole world any more than a web caller can.
310+
worldTiles.requestTiles(
311+
id,
312+
dim,
313+
(chunks ?? []).slice(0, worldTiles.MAX_TILES_PER_REQUEST),
314+
{ marks: !!marks }
315+
)
309316
)
310317

311318
// --- java installs ---

src/preload/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const api: MsmsApi = {
8080

8181
bridgeStatus: (id) => ipcRenderer.invoke(IPC.bridgeStatus, id),
8282
installBridge: (id) => ipcRenderer.invoke(IPC.bridgeInstall, id),
83-
mapTiles: (id, dim, chunks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks),
83+
mapTiles: (id, dim, chunks, marks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks, marks),
8484

8585
listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh),
8686
resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override),

src/renderer/src/components/LiveMap.tsx

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Map as MapIcon, Flame } from 'lucide-react'
44
import { fitView, heatmap, mapBounds, panBy, screenToWorld, worldToScreen, zoomAt } from '@shared/livemap'
55
import type { LivePlayer, MapView, Viewport } from '@shared/livemap'
66
import { avatarUrl } from '@shared/profile'
7+
import type { StructureMark } from '@shared/regionFormat'
78
import type { BridgeStatus } from '@shared/bridgeRelease'
89
import { BridgeNotice } from './BridgeNotice'
910

@@ -23,6 +24,16 @@ import { BridgeNotice } from './BridgeNotice'
2324

2425
const 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>

src/renderer/src/locales/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export default {
126126
'No live positions yet — this server has no MSMS-Bridge plugin. Positions arrive over the server console, so installing it opens no extra port.',
127127
heads: 'Heads',
128128
goTo: 'Centre the map here',
129+
structures: 'Structures',
130+
allStructures: 'All structures',
131+
structure_village: 'Villages',
132+
structure_dungeon: 'Dungeons & ruins',
133+
structure_temple: 'Temples',
134+
structure_fortress: 'Fortresses',
135+
structure_mine: 'Mineshafts',
129136
world: 'World',
130137
resetView: 'Reset view',
131138
installBridge: 'Install MSMS-Bridge {{version}}',

src/renderer/src/locales/tr.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ const tr: typeof en = {
128128
'Henüz canlı konum yok — bu sunucuda MSMS-Bridge eklentisi yok. Konumlar sunucu konsolu üzerinden geldiği için kurmak ek bir port açmaz.',
129129
heads: 'Kafalar',
130130
goTo: 'Haritayı buraya ortala',
131+
structures: 'Yapılar',
132+
allStructures: 'Tüm yapılar',
133+
structure_village: 'Köyler',
134+
structure_dungeon: 'Zindanlar ve kalıntılar',
135+
structure_temple: 'Tapınaklar',
136+
structure_fortress: 'Kaleler',
137+
structure_mine: 'Maden ocakları',
131138
world: 'Dünya',
132139
resetView: 'Görünümü sıfırla',
133140
installBridge: 'MSMS-Bridge {{version}} kur',

src/shared/ipc.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type { AlertRule, NewAlertRule } from './alerts'
4242
import type { McVersion, BuildInfo, CreateServerOptions, CreateProgress } from './versions'
4343
import type { ModEntry, ModrinthDetail, ModrinthHit, ModUpdateReport } from './mods'
4444
import type { BridgeInstallResult, BridgeStatus } from './bridgeRelease'
45+
import type { StructureMark } from './regionFormat'
4546
import type {
4647
WebStatus,
4748
WebUserView,
@@ -345,8 +346,12 @@ export interface MsmsApi {
345346
mapTiles(
346347
id: string,
347348
dim: string,
348-
chunks: { cx: number; cz: number }[]
349-
): Promise<{ tiles: Record<string, { c: number[]; h: number[] }>; pending: number }>
349+
chunks: { cx: number; cz: number }[],
350+
marks?: boolean
351+
): Promise<{
352+
tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }>
353+
pending: number
354+
}>
350355

351356
listJava(refresh?: boolean): Promise<JavaInstall[]>
352357
/** The Java that will actually launch, given a per-server override ('' = auto). */

src/shared/mapUi.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ function mapRefresh(){
183183
that is not one. Keeping the last good frame beats blanking the canvas on
184184
one dropped poll. */
185185
if(!d||typeof d.dimension!=='string')return;
186+
/* Tiles are keyed by chunk alone, and the nether uses the same coordinates as
187+
the overworld — so switching dimension without dropping them draws one
188+
world's terrain under another world's players. */
189+
if(MAP.dim!==d.dimension){MAP_TILES={};MAP_MARKS={}}
186190
MAP.data=d;MAP.dim=d.dimension;
187191
var dot=document.getElementById('mpDot'),state=document.getElementById('mpState');
188192
dot.className='mp-dot'+(d.bridge?' on':'');
@@ -427,7 +431,6 @@ function mapDraw(){
427431
/* The world first: everything else is drawn on top of it. */
428432
mapDrawTiles(g,w,h);
429433
mapFetchTiles();
430-
mapDrawMarks(g,w,h,dpr);
431434
var px=function(x){return mapW2S({x:x,z:0}).x*sx};
432435
var pz=function(z){return mapW2S({x:0,z:z}).y*sy};
433436
/* A grid that adapts to the zoom: a fixed 64-block step is invisible when
@@ -447,6 +450,9 @@ function mapDraw(){
447450
for(var i=0;i<d.heatmap.length;i++){var c=d.heatmap[i];
448451
g.fillStyle='rgba(220,39,39,'+(0.12+0.55*(c.count/max)).toFixed(3)+')';
449452
g.fillRect(px(c.x),pz(c.z),Math.max(2*dpr,cw),Math.max(2*dpr,ch))}}
453+
/* After the grid and the heatmap, before the players: a marker under a grid
454+
line reads as a smudge, and a player must never be hidden behind one. */
455+
mapDrawMarks(g,w,h,dpr);
450456
var ps=d.players||[];
451457
g.font=(11*dpr)+'px Inter,system-ui,sans-serif';g.textAlign='center';g.textBaseline='bottom';
452458
for(var j=0;j<ps.length;j++){var p=ps[j];var x=px(p.x),y=pz(p.z);

0 commit comments

Comments
 (0)