// renders the world generator to png files, for tuning it without starting the game: // deno run -A tools/worldgen_preview.ts [seed] [out dir] // map.png: 4096 blocks across from the terrain's heights and biomes (fast, no caves or 3d noise) // blocks.png: 768 blocks across from real generated chunks, top block with hill shading // section.png: a slice down through the world along x, showing caves, overhangs and islands import { CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, SEA_LEVEL } from "$/common/constants.ts"; import { generate_raw_chunk } from "$/common/generation.ts"; import { OverworldTerrain } from "$/common/worldgen/terrain.ts"; import { pick_biome } from "$/common/worldgen/overworld.ts"; const seed = Deno.args[0] ?? "preview"; const out = Deno.args[1] ?? "."; const NAMES = [ "bworld:stone", "bworld:dirt", "bworld:grass", "bworld:sand", "bworld:snow", "bworld:water", "bworld:coal_ore", "bworld:copper_ore", "bworld:tin_ore", "bworld:iron_ore", "bworld:gold_ore", ]; const block_ids: Record = Object.fromEntries(NAMES.map((name, i) => [name, i + 1])); const COLORS: Record = { 0: [180, 210, 255], 1: [125, 125, 125], 2: [134, 96, 67], 3: [95, 159, 53], 4: [219, 207, 163], 5: [245, 250, 255], 6: [52, 90, 190], 7: [60, 60, 60], 8: [180, 110, 80], 9: [200, 200, 200], 10: [216, 175, 147], 11: [250, 220, 60], }; const BIOME_COLORS: Record = { "bworld:deep_ocean": [20, 40, 120], "bworld:deep_frozen_ocean": [60, 80, 150], "bworld:deep_lukewarm_ocean": [20, 60, 140], "bworld:ocean": [40, 70, 180], "bworld:frozen_ocean": [120, 140, 210], "bworld:warm_ocean": [40, 110, 200], "bworld:river": [60, 110, 230], "bworld:frozen_river": [150, 170, 240], "bworld:beach": [230, 220, 150], "bworld:snowy_beach": [240, 240, 220], "bworld:stony_shore": [140, 140, 140], "bworld:plains": [140, 190, 90], "bworld:meadow": [160, 210, 110], "bworld:forest": [60, 130, 50], "bworld:dark_forest": [40, 90, 35], "bworld:swamp": [80, 110, 70], "bworld:taiga": [70, 110, 90], "bworld:snowy_plains": [235, 240, 245], "bworld:snowy_taiga": [200, 215, 220], "bworld:savanna": [190, 180, 90], "bworld:jungle": [40, 160, 40], "bworld:desert": [240, 215, 140], "bworld:alpine_highlands": [120, 150, 110], "bworld:snowy_slopes": [220, 230, 240], "bworld:stony_peaks": [150, 145, 140], "bworld:jagged_peaks": [200, 200, 215], "bworld:frozen_peaks": [210, 225, 250], "bworld:yosemite_cliffs": [120, 120, 90], "bworld:snowy_cliffs": [200, 205, 210], "bworld:painted_mountains": [200, 120, 70], "bworld:stony_spires": [110, 120, 100], "bworld:shattered_savanna": [170, 150, 80], "bworld:skylands": [255, 120, 220], }; async function write_png(path: string, width: number, height: number, rgb: Uint8Array) { const raw = new Uint8Array((width * 3 + 1) * height); for (let y = 0; y < height; y++) { raw[y * (width * 3 + 1)] = 0; raw.set(rgb.subarray(y * width * 3, (y + 1) * width * 3), y * (width * 3 + 1) + 1); } const compressed = new Uint8Array( await new Response(new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate"))).arrayBuffer(), ); const chunk = (type: string, data: Uint8Array) => { const bytes = new Uint8Array(12 + data.length); const view = new DataView(bytes.buffer); view.setUint32(0, data.length); bytes.set(new TextEncoder().encode(type), 4); bytes.set(data, 8); view.setUint32(8 + data.length, crc32(bytes.subarray(4, 8 + data.length))); return bytes; }; const header = new Uint8Array(13); const view = new DataView(header.buffer); view.setUint32(0, width); view.setUint32(4, height); header.set([8, 2, 0, 0, 0], 8); const parts = [ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", header), chunk("IDAT", compressed), chunk("IEND", new Uint8Array()), ]; const file = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); let offset = 0; for (const part of parts) { file.set(part, offset); offset += part.length; } await Deno.writeFile(path, file); } function crc32(bytes: Uint8Array) { let crc = -1; for (const byte of bytes) { crc ^= byte; for (let k = 0; k < 8; k++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ -1) >>> 0; } const shade = (color: [number, number, number], factor: number): [number, number, number] => color.map((c) => Math.max(0, Math.min(255, Math.round(c * factor)))) as [number, number, number]; // map.png { const size = 1024; const step = 4; const terrain = new OverworldTerrain(seed); const heights = new Float32Array(size * size); const rgb = new Uint8Array(size * size * 3); const started = performance.now(); for (let pz = 0; pz < size; pz++) { for (let px = 0; px < size; px++) { const x = (px - size / 2) * step; const z = (pz - size / 2) * step; const column = terrain.column(x, z); heights[pz * size + px] = column.height; const surface = Math.round(column.height); const biome = pick_biome(column, surface, false); let color = BIOME_COLORS[biome] ?? [255, 0, 255]; if (column.island) color = [255, 120, 220]; rgb.set(color, (pz * size + px) * 3); } } for (let pz = 1; pz < size; pz++) { for (let px = 1; px < size; px++) { const i = pz * size + px; const slope = (heights[i] - heights[i - 1]) + (heights[i] - heights[i - size]); const height_light = 0.75 + Math.max(0, heights[i] - SEA_LEVEL) / 400; const color = [rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]] as [number, number, number]; rgb.set(shade(color, height_light + slope * 0.03), i * 3); } } await write_png(`${out}/map.png`, size, size, rgb); console.log(`map.png: ${size * step} blocks across in ${((performance.now() - started) / 1000).toFixed(1)}s`); } // blocks.png and section.png from real chunks { const chunks = 48; const size = chunks * CHUNK_SIZE; const top = new Int32Array(size * size); const top_block = new Uint8Array(size * size); const water_depth = new Int32Array(size * size); const section = new Uint8Array(size * CHUNK_HEIGHT); const section_z = size / 2; const origin = -size / 2; const counts = new Map(); let solid = 0; let underground_air = 0; const started = performance.now(); for (let cz = 0; cz < chunks; cz++) { for (let cx = 0; cx < chunks; cx++) { const chunk_x = origin / CHUNK_SIZE + cx; const chunk_z = origin / CHUNK_SIZE + cz; const { blocks } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids); for (let lz = 0; lz < CHUNK_SIZE; lz++) { for (let lx = 0; lx < CHUNK_SIZE; lx++) { const px = cx * CHUNK_SIZE + lx; const pz = cz * CHUNK_SIZE + lz; let y = CHUNK_HEIGHT - 1; let depth = 0; while (y > 0) { const b = blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx]; if (b === block_ids["bworld:water"]) depth++; else if (b !== 0) break; y--; } top[pz * size + px] = y; top_block[pz * size + px] = blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx]; water_depth[pz * size + px] = depth; let seen_ground = false; for (let yy = CHUNK_HEIGHT - 1; yy >= 0; yy--) { const b = blocks[yy * CHUNK_AREA + lz * CHUNK_SIZE + lx]; counts.set(b, (counts.get(b) ?? 0) + 1); if (b !== 0 && b !== block_ids["bworld:water"]) { seen_ground = true; solid++; } else if (seen_ground && b === 0) { underground_air++; } if (pz === section_z) section[(CHUNK_HEIGHT - 1 - yy) * size + px] = b; } } } } } const took = performance.now() - started; const rgb = new Uint8Array(size * size * 3); for (let pz = 1; pz < size; pz++) { for (let px = 1; px < size; px++) { const i = pz * size + px; let color = COLORS[top_block[i]] ?? [255, 0, 255]; const slope = (top[i] - top[i - 1]) + (top[i] - top[i - size]); color = shade(color, 0.85 + slope * 0.06 + (top[i] - SEA_LEVEL) / 500); if (water_depth[i] > 0) { color = shade(COLORS[6], 1.2 - Math.min(water_depth[i], 40) / 60); } rgb.set(color, i * 3); } } await write_png(`${out}/blocks.png`, size, size, rgb); const section_rgb = new Uint8Array(size * CHUNK_HEIGHT * 3); for (let i = 0; i < size * CHUNK_HEIGHT; i++) { section_rgb.set(section[i] === 0 ? [30, 30, 40] : COLORS[section[i]] ?? [255, 0, 255], i * 3); if (section[i] === 0 && Math.floor(i / size) < CHUNK_HEIGHT - 1 - SEA_LEVEL + 1) { // sky const row = Math.floor(i / size); if (row < CHUNK_HEIGHT - SEA_LEVEL) section_rgb.set([180, 210, 255], i * 3); } } await write_png(`${out}/section.png`, size, CHUNK_HEIGHT, section_rgb); console.log(`blocks.png: ${chunks * chunks} chunks, ${(took / (chunks * chunks)).toFixed(2)} ms per chunk`); console.log(`underground air: ${(underground_air / (solid + underground_air) * 100).toFixed(1)}% of the ground`); const total = [...counts.values()].reduce((a, b) => a + b, 0); for (const [nid, count] of [...counts].sort((a, b) => b[1] - a[1])) { console.log(` ${nid === 0 ? "air" : NAMES[nid - 1]}: ${(count / total * 100).toFixed(2)}%`); } }