diff --git a/app/layout.tsx b/app/layout.tsx index 6465921..5b47a31 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import "./block-theme.css"; import "./account.css"; +import "./map-theme.css"; export const metadata: Metadata = { title: { default: "MineGeo — Get lost in a familiar world", diff --git a/app/map-theme.css b/app/map-theme.css new file mode 100644 index 0000000..feef5a8 --- /dev/null +++ b/app/map-theme.css @@ -0,0 +1,119 @@ +/* The same parchment surrounds the editor preview and interactive player map. */ +.detail-map, +.map-surface { + border: 22px solid transparent; + border-image: url("/images/map-parchment.svg") 12 fill / 22px / 0 stretch; + background: #ddc79f; + box-shadow: 4px 6px 0 #0005; +} +.detail-map { + margin-top: 12px; +} +.detail-map img, +.map-surface .leaflet-image-layer { + image-rendering: pixelated; +} +.map-surface { + border-width: 14px; + border-image-width: 14px; + height: 290px; +} +.map-surface .leaflet-container { + background: #ddc79f; + outline: 1px solid #9d815c; +} +.map-surface .leaflet-bar { + border: 2px solid #806543; + border-radius: 0; + box-shadow: 2px 2px 0 #0004; +} +.map-surface .leaflet-bar a { + background: #e5c99a; + color: #493a28; + border-color: #b39970; + border-radius: 0; +} +.map-surface .leaflet-bar a:hover, +.map-surface .leaflet-bar a:focus-visible { + background: #f3ddb1; +} +.map-surface .leaflet-bar a.leaflet-disabled { + background: #c9b18a; + color: #847256; +} +.pin-marker { + background: #e45e66; + border-color: #fff1d0; + border-radius: 50%; + transform: none; + box-shadow: + 0 0 0 1px #763d36, + 1px 2px 0 #0005; +} +.actual-marker { + background: #90c55b; + box-shadow: + 0 0 0 1px #426132, + 1px 2px 0 #0005; +} +.game-map { + width: 340px; + border: 2px solid #806543; + background: #241e18; +} +.game-map.expanded { + width: min(650px, calc(100vw - 48px)); +} +.map-topline, +.map-topline button, +.map-actions small { + color: #e5c99a; +} +.map-topline { + padding: 9px 12px; +} +.map-actions { + padding: 12px; +} +.map-actions .button.primary { + background: #715034; + color: #fff1d0; + border-color: #af8956; + box-shadow: + inset 3px 3px 0 #a67c4e, + inset -3px -3px 0 #49331f, + 2px 2px 0 #0005; +} +.map-actions .button.primary:hover:not(:disabled) { + background: #87613e; +} +.expanded .map-surface { + height: min(540px, 58vh); + border-width: 22px; + border-image-width: 22px; +} +.result-map { + border: 0; +} +@media (max-width: 600px) { + .detail-map { + border-width: 16px; + border-image-width: 16px; + } + .game-map { + width: 250px; + } + .game-map .map-surface { + height: 210px; + border-width: 12px; + border-image-width: 12px; + } + .game-map.expanded { + width: calc(100vw - 28px); + } + .game-map.expanded .map-surface { + height: min(440px, 50vh); + border-width: 16px; + border-image-width: 16px; + } +} diff --git a/components/GuessMap.tsx b/components/GuessMap.tsx index f98f58b..11e9365 100644 --- a/components/GuessMap.tsx +++ b/components/GuessMap.tsx @@ -44,6 +44,7 @@ export function GuessMap({ crs: L.CRS.Simple, minZoom: -5, maxZoom: 3, + zoomSnap: 0, maxBounds: b.pad(0.15), attributionControl: false, }); @@ -55,7 +56,10 @@ export function GuessMap({ ), ); overlay.addTo(m); - m.fitBounds(b, { padding: [5, 5] }); + m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3)); + m.fitBounds(b, { padding: [0, 0], animate: false }); + const fitZoom = m.getZoom(); + m.setMinZoom(fitZoom); markers.current = L.layerGroup().addTo(m); setGeneration((v) => v + 1); m.on("click", (e: Leaflet.LeafletMouseEvent) => { @@ -70,7 +74,19 @@ export function GuessMap({ ) callbacks.current.onPin?.({ x, z }); }); - const observer = new ResizeObserver(() => m.invalidateSize()); + let fittedZoom = fitZoom; + const observer = new ResizeObserver(() => { + // Expanding the map should reveal the whole survey at its new size. + // Preserve a player's chosen zoom and center while inspecting terrain. + const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01; + m.invalidateSize(); + m.setMinZoom(-5); + fittedZoom = m.getBoundsZoom(b); + m.setMaxZoom(Math.max(3, fittedZoom + 3)); + if (wasFitted || m.getZoom() < fittedZoom) + m.fitBounds(b, { padding: [0, 0], animate: false }); + m.setMinZoom(fittedZoom); + }); observer.observe(root.current); cleanup = () => { observer.disconnect(); @@ -85,7 +101,7 @@ export function GuessMap({ disposed = true; cleanup(); }; - }, [bounds, image]); + }, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]); useEffect(() => { const L = lib.current, group = markers.current; diff --git a/docs/surface-maps.md b/docs/surface-maps.md new file mode 100644 index 0000000..829f05a --- /dev/null +++ b/docs/surface-maps.md @@ -0,0 +1,27 @@ +# Surface maps + +Plugin exports now produce a flat, north-up map rather than a textured aerial +render. The map uses up to 128 cells along its longest side, chooses the dominant +surface material in each cell, shades slopes and water depth, and retains the +original world bounds. Transparent glass and small vegetation do not hide the +ground. Unknown blocks use stone gray; material colors are approximations. + +Uncaptured terrain appears as parchment. The saved WebP uses lossless compression +and nearest-neighbor scaling, with a subtle grid. Fabric maps retain their +client-captured colors and are also compressed losslessly. The web preview, +player guessing map and results share a parchment frame. + +New plugin uploads automatically use the new renderer. Existing processed images +are not regenerated merely by restarting the web server. For an unpublished +original capture, use the existing `npm run reprocess -- ` workflow +with the worker stopped, then restart the worker. Published revisions retain +their approved media. + +To create just a local map image from a retained export, without rerendering +panoramas or changing stored data: + +```sh +npm run render:map -- path/to/terrain.json.gz path/to/map.webp +``` + +The parchment is a local SVG UI asset; no remote textures are required. diff --git a/package.json b/package.json index 3bc19a8..1e85216 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "worker": "tsx worker/main.ts", "test": "tsx --test tests/*.test.ts", "test:renderer": "node scripts/test-renderer.mjs", + "render:map": "node scripts/render-map.mjs", "test:e2e": "playwright test", "typecheck": "tsc --noEmit", "renderer:proof": "node scripts/proof.mjs", diff --git a/public/images/map-parchment.svg b/public/images/map-parchment.svg new file mode 100644 index 0000000..06a6f2d --- /dev/null +++ b/public/images/map-parchment.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/renderer/render.mjs b/renderer/render.mjs index 29d82d9..346c6c1 100644 --- a/renderer/render.mjs +++ b/renderer/render.mjs @@ -5,6 +5,7 @@ import { resolve, sep } from "node:path"; import sharp from "sharp"; import { assetsRoot, prepareAssets } from "../scripts/assets.mjs"; import { assertTerrainImage } from "./image-validation.mjs"; +import { renderSurfaceMap } from "./surface-map.mjs"; /** @param {import('../shared/protocol').TerrainSnapshot} snapshot * @param {Array<{id:string,x:number,y:number,z:number}>} viewpoints * @param {string} output @@ -202,13 +203,7 @@ export async function renderSnapshot( } } await report({ percent: 85, message: "Rendering the surface map" }); - const map = await page.evaluate(() => window.renderMap(1024)); - const mapBytes = Buffer.from(map.split(",")[1], "base64"); - await assertTerrainImage(mapBytes, "Surface map"); - if (map.startsWith("data:image/webp;")) - await writeFile(`${output}/map.webp`, mapBytes); - else - await sharp(mapBytes).webp({ quality: 90 }).toFile(`${output}/map.webp`); + stats.map = await renderSurfaceMap(snapshot, `${output}/map.webp`); await report({ percent: 90, message: "Rendered all panoramas and the surface map", diff --git a/renderer/surface-map.mjs b/renderer/surface-map.mjs new file mode 100644 index 0000000..50d1ffa --- /dev/null +++ b/renderer/surface-map.mjs @@ -0,0 +1,213 @@ +import sharp from "sharp"; + +// A map is a survey of the surface, not a photograph of block textures. +// Keep the palette and relief separate from the lighting used by panoramas. +const palette = { + grass: 0x7fb238, + foliage: 0x007c00, + sand: 0xf7e9a3, + stone: 0x707070, + dirt: 0x976d4d, + wood: 0x8f7748, + water: 0x356baf, + snow: 0xffffff, + ice: 0xa0a0ff, + clay: 0xa4a8b8, + lava: 0xff0000, + black: 0x191919, + white: 0xfffcf5, + orange: 0xd87f33, + magenta: 0xb24cd8, + light_blue: 0x6699d8, + yellow: 0xe5e533, + lime: 0x7fcc19, + pink: 0xf27fa5, + gray: 0x4c4c4c, + light_gray: 0x999999, + cyan: 0x4c7f99, + purple: 0x7f3fb2, + blue: 0x334cb2, + brown: 0x664c33, + green: 0x667f33, + red: 0x993333, + gold: 0xfaee4d, + iron: 0xa7a7a7, + diamond: 0x5cdbd5, + emerald: 0x00d93a, + terracotta: 0x985e43, + netherrack: 0x700200, + deepslate: 0x646464, + copper: 0xc16b4f, + oxidized: 0x52a384, + crimson: 0x943f61, + warped: 0x3a8e8c, +}; +const entries = Object.entries(palette); +const ids = Object.fromEntries(entries.map(([name], id) => [name, id])); +const invisible = + /^(?:air|cave_air|void_air|barrier|structure_void|light|.*glass(?:_pane)?|(?:wall_)?torch|.*_torch|.*_flower|.*_sapling|short_grass|tall_grass|fern|large_fern|dead_bush|vine|glow_lichen|seagrass|tall_seagrass|kelp|kelp_plant|.*_rail|rail|redstone_wire|tripwire|tripwire_hook)$/; + +function material(state) { + const name = state.split("[")[0].replace(/^minecraft:/, ""); + if (invisible.test(name)) return -1; + if (name === "water" || name === "bubble_column") return ids.water; + if (/snow|quartz|calcite/.test(name)) return ids.snow; + if (name.includes("ice")) return ids.ice; + if (name === "lava" || name.includes("magma")) return ids.lava; + if (name === "grass_block" || name === "moss_block" || name === "moss_carpet") + return ids.grass; + if (/leaves|lily_pad/.test(name)) return ids.foliage; + if (/^(?:red_sand|red_sandstone)/.test(name)) return ids.orange; + if (/sand|end_stone|birch/.test(name)) return ids.sand; + if (/dirt|mud|farmland|rooted|granite|jungle/.test(name)) return ids.dirt; + if (name === "clay") return ids.clay; + if (/obsidian|coal_block|blackstone/.test(name)) return ids.black; + for (const [color] of entries) { + if ( + name.startsWith(color + "_") && + /wool|carpet|concrete|terracotta|bed|shulker/.test(name) + ) + return ids[color]; + } + if (/terracotta|brick/.test(name) && !/stone|nether|end/.test(name)) + return ids.terracotta; + if (/gold_block|raw_gold/.test(name)) return ids.gold; + if (/iron_block|raw_iron/.test(name)) return ids.iron; + if (/diamond_block|prismarine|sea_lantern/.test(name)) return ids.diamond; + if (name === "emerald_block") return ids.emerald; + if (/copper/.test(name)) + return /oxidized|weathered/.test(name) ? ids.oxidized : ids.copper; + if (/crimson|nether_wart/.test(name)) return ids.crimson; + if (/warped/.test(name)) return ids.warped; + if (/netherrack|nether_brick/.test(name)) return ids.netherrack; + if (/deepslate|basalt/.test(name)) return ids.deepslate; + if (/spruce|dark_oak/.test(name)) return ids.brown; + if ( + /acacia|mangrove|cherry|pale_oak|oak|bamboo|planks|log|wood|chest|crafting|barrel|bookshelf/.test( + name, + ) + ) + return ids.wood; + return ids.stone; +} + +/** Rasterize north (-Z) at the top, preserving half-open world bounds. + * @param {import('../shared/protocol').TerrainSnapshot} snapshot + * @param {{resolution?:number}} [options] + */ +export function surfacePixels(snapshot, { resolution = 128 } = {}) { + const { minX, maxX, minZ, maxZ } = snapshot.bounds; + const spanX = maxX - minX, + spanZ = maxZ - minZ; + if (!Number.isInteger(resolution) || resolution < 1 || resolution > 512) + throw new Error("Map resolution must be an integer from 1 to 512"); + const scale = Math.min(1, resolution / Math.max(spanX, spanZ)); + const width = Math.ceil(spanX * scale), + height = Math.ceil(spanZ * scale); + const top = new Int16Array(spanX * spanZ).fill(-32768); + const bed = new Int16Array(top.length).fill(-32768); + const colors = new Int16Array(top.length).fill(-1); + const stateCache = new Map(); + let columns = 0; + for (const [x, y, z, state] of snapshot.blocks) { + if (x < minX || x >= maxX || z < minZ || z >= maxZ) continue; + let color = stateCache.get(state); + if (color === undefined) { + color = material(state); + stateCache.set(state, color); + } + if (color < 0) continue; + const i = (z - minZ) * spanX + x - minX; + if (color !== ids.water && y > bed[i]) bed[i] = y; + if (y > top[i]) { + if (colors[i] < 0) columns++; + top[i] = y; + colors[i] = color; + } + } + if (!columns) throw new Error("Surface map contains no mapped terrain"); + const data = Buffer.alloc(width * height * 4); + const heights = new Float32Array(width * height).fill(NaN); + const votes = new Uint32Array(entries.length); + for (let pz = 0; pz < height; pz++) { + for (let px = 0; px < width; px++) { + votes.fill(0); + let count = 0, + totalHeight = 0, + totalDepth = 0, + waterCount = 0; + const x0 = Math.floor((px * spanX) / width), + x1 = Math.floor(((px + 1) * spanX) / width); + const z0 = Math.floor((pz * spanZ) / height), + z1 = Math.floor(((pz + 1) * spanZ) / height); + for (let z = z0; z < z1; z++) + for (let x = x0; x < x1; x++) { + const i = z * spanX + x; + if (colors[i] < 0) continue; + votes[colors[i]]++; + count++; + totalHeight += top[i]; + if (colors[i] === ids.water) { + // Exports retain only the upper terrain layers. A missing bed means + // deep water; it must not turn into a bright shallow coastline. + totalDepth += bed[i] === -32768 ? 24 : Math.max(1, top[i] - bed[i]); + waterCount++; + } + } + const pixel = pz * width + px, + offset = pixel * 4; + if (!count) { + const paper = + (Math.floor(px / 4) + Math.floor(pz / 4)) % 2 ? 0xd4bb8c : 0xddc79f; + data[offset] = paper >> 16; + data[offset + 1] = (paper >> 8) & 255; + data[offset + 2] = paper & 255; + data[offset + 3] = 255; + continue; + } + let dominant = 0; + for (let c = 1; c < votes.length; c++) + if (votes[c] > votes[dominant]) dominant = c; + heights[pixel] = totalHeight / count; + const north = heights[pixel - width]; + const slope = Number.isFinite(north) + ? (heights[pixel] - north) / (spanZ / height) + : 0; + const shade = + dominant === ids.water + ? totalDepth / waterCount > 8 + ? 0.61 + : totalDepth / waterCount > 3 + ? 0.76 + : 0.9 + : slope > 0.25 + ? 1 + : slope < -0.25 + ? 0.71 + : 0.86; + const rgb = entries[dominant][1]; + data[offset] = Math.floor((rgb >> 16) * shade); + data[offset + 1] = Math.floor(((rgb >> 8) & 255) * shade); + data[offset + 2] = Math.floor((rgb & 255) * shade); + data[offset + 3] = 255; + } + } + return { data, width, height, channels: 4, columns }; +} + +/** Save without lossy ringing around the map's square pixels. */ +export async function renderSurfaceMap(snapshot, output) { + const { data, width, height, columns } = surfacePixels(snapshot); + await sharp(data, { raw: { width, height, channels: 4 } }) + .resize(width * 8, height * 8, { kernel: "nearest" }) + .composite([ + { + input: Buffer.from( + ``, + ), + }, + ]) + .webp({ lossless: true }) + .toFile(output); + return { width, height, columns }; +} diff --git a/scripts/render-map.mjs b/scripts/render-map.mjs new file mode 100644 index 0000000..6124d1d --- /dev/null +++ b/scripts/render-map.mjs @@ -0,0 +1,20 @@ +import { readFile, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { renderSurfaceMap } from "../renderer/surface-map.mjs"; + +const [input, output] = process.argv.slice(2); +if (!input || !output) + throw new Error( + "Usage: npm run render:map -- ", + ); +const terrain = JSON.parse( + gunzipSync(await readFile(input), { + maxOutputLength: 256 * 1024 * 1024, + }).toString(), +); +await mkdir(dirname(resolve(output)), { recursive: true }); +const report = await renderSurfaceMap(terrain, output); +console.log( + `Rendered ${report.width} × ${report.height} map cells from ${report.columns.toLocaleString()} surface columns: ${output}`, +); diff --git a/tests/surface-map.test.ts b/tests/surface-map.test.ts new file mode 100644 index 0000000..a28747d --- /dev/null +++ b/tests/surface-map.test.ts @@ -0,0 +1,123 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { surfacePixels } from "../renderer/surface-map.mjs"; +import type { TerrainSnapshot } from "../shared/protocol"; + +function pixel(map: ReturnType, x: number, z: number) { + return [ + ...map.data.subarray((z * map.width + x) * 4, (z * map.width + x + 1) * 4), + ]; +} + +test("surface map keeps north up, negative coordinates, rectangular bounds and uncovered parchment", () => { + const map = surfacePixels({ + bounds: { minX: -2, maxX: 2, minZ: -3, maxZ: -1 }, + coverage: [], + blocks: [ + [-2, 64, -3, "minecraft:snow_block"], + [1, 64, -2, "minecraft:grass_block"], + [2, 90, -2, "minecraft:snow_block"], // half-open boundary + [-2, 90, -1, "minecraft:snow_block"], + ], + }); + assert.equal(map.width, 4); + assert.equal(map.height, 2); + assert.equal(map.columns, 2); + const north = pixel(map, 0, 0); + assert.equal(north[0], north[1]); + assert.equal(north[1], north[2]); + const south = pixel(map, 3, 1); + assert.ok(south[1] > south[0] && south[0] > south[2]); + const blank = pixel(map, 3, 0); + assert.ok(blank[0] > blank[1] && blank[1] > blank[2]); + assert.equal(blank[3], 255); +}); + +test("the highest mapped block wins regardless of export order and glass does not obscure terrain", () => { + const terrain: TerrainSnapshot = { + bounds: { minX: 0, maxX: 1, minZ: 0, maxZ: 1 }, + coverage: [], + blocks: [ + [0, 63, 0, "minecraft:dirt"], + [0, 64, 0, "minecraft:grass_block[snowy=false]"], + [0, 70, 0, "minecraft:glass"], + [0, 71, 0, "minecraft:short_grass"], + ], + }; + const first = surfacePixels(terrain); + const reversed = surfacePixels({ + ...terrain, + blocks: [...terrain.blocks].reverse(), + }); + assert.deepEqual(first.data, reversed.data); + const rgb = pixel(first, 0, 0); + assert.ok(rgb[1] > rgb[0] && rgb[0] > rgb[2]); +}); + +test("depth shading separates shallow, deep and unknown-bed water without texture speckle", () => { + const map = surfacePixels({ + bounds: { minX: 0, maxX: 3, minZ: 0, maxZ: 1 }, + coverage: [], + blocks: [ + [0, 64, 0, "minecraft:water[level=0]"], + [0, 63, 0, "minecraft:sand"], + [1, 64, 0, "minecraft:water[level=0]"], + [1, 52, 0, "minecraft:stone"], + [2, 64, 0, "minecraft:water[level=0]"], + ], + }); + const shallow = pixel(map, 0, 0), + deep = pixel(map, 1, 0); + assert.ok(shallow[2] > shallow[1] && shallow[1] > shallow[0]); + assert.ok(shallow[2] > deep[2]); + assert.deepEqual(deep, pixel(map, 2, 0)); +}); + +test("height relief shades slopes while flat ground stays uniform", () => { + const blocks: TerrainSnapshot["blocks"] = [64, 64, 66, 64].map((y, z) => [ + 0, + y, + z, + "minecraft:stone", + ]); + const map = surfacePixels({ + bounds: { minX: 0, maxX: 1, minZ: 0, maxZ: 4 }, + coverage: [], + blocks, + }); + assert.deepEqual(pixel(map, 0, 0), pixel(map, 0, 1)); + assert.ok(pixel(map, 0, 2)[0] > pixel(map, 0, 1)[0]); + assert.ok(pixel(map, 0, 3)[0] < pixel(map, 0, 1)[0]); +}); + +test("map cells use the dominant surface material instead of a textured average", () => { + const blocks: TerrainSnapshot["blocks"] = []; + for (let x = 0; x < 4; x++) + for (let z = 0; z < 4; z++) + blocks.push([ + x, + 64, + z, + x === 0 ? "minecraft:stone" : "minecraft:grass_block", + ]); + const map = surfacePixels( + { bounds: { minX: 0, maxX: 4, minZ: 0, maxZ: 4 }, coverage: [], blocks }, + { resolution: 1 }, + ); + assert.equal(map.width, 1); + assert.equal(map.height, 1); + const rgb = pixel(map, 0, 0); + assert.ok(rgb[1] > rgb[0] && rgb[0] > rgb[2]); +}); + +test("empty exports fail instead of producing a playable blank map", () => { + assert.throws( + () => + surfacePixels({ + bounds: { minX: 0, maxX: 16, minZ: 0, maxZ: 16 }, + coverage: [], + blocks: [], + }), + /no mapped terrain/, + ); +}); diff --git a/worker/main.ts b/worker/main.ts index 23c3908..6ec797a 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -193,7 +193,7 @@ async function processJob(job: { await sharp(await readFile(`${directory}/raw/map.png`), { limitInputPixels: 16777216, }) - .webp({ quality: 90 }) + .webp({ lossless: true }) .toFile(`${output}/map.webp`); let processedFaces = 0; for (const point of manifest.availableViewpoints ?? manifest.viewpoints) {