253 lines
8.9 KiB
TypeScript
253 lines
8.9 KiB
TypeScript
// 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",
|
|
"bworld:log",
|
|
"bworld:leaves",
|
|
];
|
|
const block_ids: Record<string, number> = Object.fromEntries(NAMES.map((name, i) => [name, i + 1]));
|
|
const COLORS: Record<number, [number, number, number]> = {
|
|
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],
|
|
12: [100, 70, 40],
|
|
13: [45, 110, 35],
|
|
};
|
|
|
|
const BIOME_COLORS: Record<string, [number, number, number]> = {
|
|
"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<number, number>();
|
|
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)}%`);
|
|
}
|
|
}
|