214 lines
7.8 KiB
JavaScript
214 lines
7.8 KiB
JavaScript
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(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width * 8}" height="${height * 8}"><defs><pattern id="grid" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M8 0H0V8" fill="none" stroke="#f3edd8" stroke-opacity=".07" stroke-width="1"/></pattern></defs><rect width="100%" height="100%" fill="url(#grid)"/></svg>`,
|
|
),
|
|
},
|
|
])
|
|
.webp({ lossless: true })
|
|
.toFile(output);
|
|
return { width, height, columns };
|
|
}
|