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