482 lines
13 KiB
TypeScript
482 lines
13 KiB
TypeScript
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
|
|
import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
|
|
import type { FeatureChunk } from "$/common/mod_api/worldgen.ts";
|
|
import type { OreJson } from "$/common/mod_data.ts";
|
|
|
|
// generation runs in a worker now, so it only needs somewhere to put blocks
|
|
export interface BlockSink {
|
|
add_block(block: { x: number; y: number; z: number; id: string }): void;
|
|
// the surface height and biome of each column, for later passes
|
|
set_column?(x: number, z: number, height: number, biome: string): void;
|
|
}
|
|
|
|
type Biome =
|
|
| "desert"
|
|
| "plains"
|
|
| "forest"
|
|
| "jungle"
|
|
| "tundra"
|
|
| "taiga"
|
|
| "snow"
|
|
| "savanna"
|
|
| "swamp";
|
|
|
|
type OreDef = {
|
|
id: string;
|
|
min_y: number;
|
|
max_y: number;
|
|
scale: number;
|
|
threshold: number;
|
|
};
|
|
|
|
const ORES: OreDef[] = [
|
|
{ id: "bworld:coal_ore", min_y: 20, max_y: 120, scale: 0.05, threshold: 0.55 },
|
|
{ id: "bworld:copper_ore", min_y: 10, max_y: 80, scale: 0.06, threshold: 0.6 },
|
|
{ id: "bworld:tin_ore", min_y: 5, max_y: 60, scale: 0.06, threshold: 0.62 },
|
|
{ id: "bworld:iron_ore", min_y: 5, max_y: 50, scale: 0.05, threshold: 0.65 },
|
|
{ id: "bworld:gold_ore", min_y: 0, max_y: 30, scale: 0.04, threshold: 0.7 },
|
|
];
|
|
|
|
function get_biome(temp: number, moisture: number): Biome {
|
|
if (temp > 0.6) {
|
|
if (moisture < -0.2) {
|
|
return "desert";
|
|
}
|
|
if (moisture > 0.4) {
|
|
return "jungle";
|
|
}
|
|
return "savanna";
|
|
}
|
|
if (temp > 0) {
|
|
if (moisture > 0.5) {
|
|
return "swamp";
|
|
}
|
|
if (moisture > 0) {
|
|
return "forest";
|
|
}
|
|
return "plains";
|
|
}
|
|
if (temp > -0.5) {
|
|
return "taiga";
|
|
}
|
|
return "tundra";
|
|
}
|
|
|
|
function get_surface_block(biome: Biome) {
|
|
if (biome === "desert") {
|
|
return "bworld:sand";
|
|
} else if (biome === "tundra") {
|
|
return "bworld:snow";
|
|
}
|
|
return "bworld:grass";
|
|
}
|
|
|
|
function biome_height_modifier(biome: Biome) {
|
|
if (biome === "desert") {
|
|
return 0.2;
|
|
}
|
|
if (biome === "plains") {
|
|
return 0.4;
|
|
}
|
|
if (biome === "forest") {
|
|
return 0.5;
|
|
}
|
|
if (biome === "jungle") {
|
|
return 0.45;
|
|
}
|
|
if (biome === "taiga") {
|
|
return 0.55;
|
|
}
|
|
if (biome === "tundra") {
|
|
return 0.35;
|
|
}
|
|
if (biome === "savanna") {
|
|
return 0.4;
|
|
}
|
|
if (biome === "swamp") {
|
|
return 0.35;
|
|
}
|
|
|
|
return 0.4;
|
|
}
|
|
|
|
function fractal_noise(noise: NoiseFunction2D, x: number, y: number, octaves = 2) {
|
|
let value = 0;
|
|
let amp = 1;
|
|
let freq = 1;
|
|
let max = 0;
|
|
for (let i = 0; i < octaves; i++) {
|
|
value += noise(x * freq, y * freq) * amp;
|
|
max += amp;
|
|
amp *= 0.5;
|
|
freq *= 2;
|
|
}
|
|
return value / max;
|
|
}
|
|
|
|
function get_terrain_height(base: number, biome: Biome, x: number, z: number, noise: NoiseFunction2D) {
|
|
const biomeMod = biome_height_modifier(biome);
|
|
|
|
const main = fractal_noise(noise, x * 0.003, z * 0.003) * 15;
|
|
|
|
const detail = fractal_noise(noise, x * 0.01, z * 0.01) * 3;
|
|
|
|
return Math.floor(base + biomeMod * 20 + main + detail);
|
|
}
|
|
|
|
function can_place_tree(tree_map: boolean[][], local_x: number, local_z: number) {
|
|
const TREE_SPACING = 4;
|
|
for (let dx = -TREE_SPACING; dx <= TREE_SPACING; dx++) {
|
|
for (let dz = -TREE_SPACING; dz <= TREE_SPACING; dz++) {
|
|
const nx = local_x + dx;
|
|
const nz = local_z + dz;
|
|
if (nx >= 0 && nx < CHUNK_SIZE && nz >= 0 && nz < CHUNK_SIZE && tree_map[nx][nz]) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function place_tree(dimension: BlockSink, rng: Alea, x: number, y: number, z: number, biome: Biome) {
|
|
const height = Math.floor(rng.next() * 3) + (biome === "jungle" ? 8 : 4);
|
|
const trunk_block = "bworld:log";
|
|
const leaves_block = "bworld:leaves";
|
|
|
|
for (let i = 0; i < height; i++) {
|
|
dimension.add_block({ x, y: y + i, z, id: trunk_block });
|
|
}
|
|
|
|
for (let dx = -2; dx <= 2; dx++) {
|
|
for (let dz = -2; dz <= 2; dz++) {
|
|
for (let dy = -1; dy <= 1; dy++) {
|
|
if (Math.abs(dx) + Math.abs(dz) + Math.abs(dy) <= 3) {
|
|
dimension.add_block({
|
|
x: x + dx,
|
|
y: y + height + dy,
|
|
z: z + dz,
|
|
id: leaves_block,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const TREE_THRESHOLD: Record<Biome, number> = {
|
|
forest: 0.5,
|
|
jungle: 0.3,
|
|
taiga: 0.6,
|
|
plains: 0.95,
|
|
desert: 1,
|
|
tundra: 1,
|
|
savanna: 0.65,
|
|
swamp: 0.5,
|
|
snow: 0.8,
|
|
};
|
|
|
|
function should_place_tree(feature_noise: NoiseFunction2D, biome: Biome, x: number, z: number) {
|
|
const n = feature_noise(x * 0.1, z * 0.1);
|
|
return n > (TREE_THRESHOLD[biome] ?? 0.8);
|
|
}
|
|
|
|
interface SeedNoises {
|
|
height_noise: NoiseFunction2D;
|
|
temp_noise: NoiseFunction2D;
|
|
moisture_noise: NoiseFunction2D;
|
|
feature_noise: NoiseFunction2D;
|
|
ore_noises: NoiseFunction3D[];
|
|
}
|
|
|
|
// building the permutation tables is expensive, only do it once per seed
|
|
const noise_cache = new Map<string, SeedNoises>();
|
|
|
|
function get_noises(seed: string): SeedNoises {
|
|
let noises = noise_cache.get(seed);
|
|
if (!noises) {
|
|
noises = {
|
|
height_noise: create_noise_2d(new Alea(seed + "_height")),
|
|
temp_noise: create_noise_2d(new Alea(seed + "_temp")),
|
|
moisture_noise: create_noise_2d(new Alea(seed + "_moisture")),
|
|
feature_noise: create_noise_2d(new Alea(seed + "_feature")),
|
|
ore_noises: ORES.map((ore) => create_noise_3d(new Alea(seed + "_" + ore.id))),
|
|
};
|
|
noise_cache.set(seed, noises);
|
|
}
|
|
return noises;
|
|
}
|
|
|
|
export function generate_chunk(dimension: BlockSink, cx: number, cz: number, seed = "seed") {
|
|
const { height_noise, temp_noise, moisture_noise, feature_noise, ore_noises } = get_noises(seed);
|
|
// seeded per chunk so every client generates the exact same terrain
|
|
const rng = new Alea(`${seed}_chunk_${cx}_${cz}`);
|
|
|
|
const biome_scale = 0.003;
|
|
const terrain_scale = 0.01;
|
|
|
|
const tree_map: boolean[][] = Array.from({ length: CHUNK_SIZE }, () => Array(CHUNK_SIZE).fill(false));
|
|
|
|
for (let x = 0; x < CHUNK_SIZE; x++) {
|
|
for (let z = 0; z < CHUNK_SIZE; z++) {
|
|
const wx = cx * CHUNK_SIZE + x;
|
|
const wz = cz * CHUNK_SIZE + z;
|
|
|
|
const temp = temp_noise(wx * biome_scale, wz * biome_scale);
|
|
const moisture = moisture_noise(wx * biome_scale, wz * biome_scale);
|
|
const biome = get_biome(temp, moisture);
|
|
|
|
const height_noise_value = fractal_noise(height_noise, wx * terrain_scale, wz * terrain_scale);
|
|
const base_height = (height_noise_value + 1) * 15 + 50;
|
|
const height = get_terrain_height(base_height, biome, wx, wz, height_noise);
|
|
|
|
const surface_block = get_surface_block(biome);
|
|
dimension.set_column?.(wx, wz, height, `bworld:${biome}`);
|
|
|
|
for (let y = 0; y <= height; y++) {
|
|
let block = "bworld:stone";
|
|
|
|
if (y < height - 3) {
|
|
for (let i = 0; i < ORES.length; i++) {
|
|
const ore = ORES[i];
|
|
|
|
if (y >= ore.min_y && y <= ore.max_y) {
|
|
const noise = ore_noises[i](
|
|
wx * ore.scale,
|
|
y * ore.scale,
|
|
wz * ore.scale,
|
|
);
|
|
|
|
if (noise > ore.threshold) {
|
|
block = ore.id;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (y === height) {
|
|
block = surface_block;
|
|
} else if (y > height - 4) {
|
|
block = "bworld:dirt";
|
|
}
|
|
|
|
if (biome === "swamp" && y === height && rng.next() < 0.2) {
|
|
block = "bworld:water";
|
|
}
|
|
|
|
dimension.add_block({ x: wx, y, z: wz, id: block });
|
|
}
|
|
|
|
if (should_place_tree(feature_noise, biome, wx, wz) && can_place_tree(tree_map, x, z)) {
|
|
place_tree(dimension, rng, wx, height + 1, wz, biome);
|
|
tree_map[x][z] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// noise the way mods get it: create_noise_2d(new Alea(seed + "_" + name)), the same as the base terrain's
|
|
const mod_noise_2d = new Map<string, NoiseFunction2D>();
|
|
const mod_noise_3d = new Map<string, NoiseFunction3D>();
|
|
|
|
export function named_noise_2d(seed: string, name: string): NoiseFunction2D {
|
|
const key = `${seed}_${name}`;
|
|
let noise = mod_noise_2d.get(key);
|
|
if (!noise) {
|
|
noise = create_noise_2d(new Alea(key));
|
|
mod_noise_2d.set(key, noise);
|
|
}
|
|
return noise;
|
|
}
|
|
|
|
export function named_noise_3d(seed: string, name: string): NoiseFunction3D {
|
|
const key = `${seed}_${name}`;
|
|
let noise = mod_noise_3d.get(key);
|
|
if (!noise) {
|
|
noise = create_noise_3d(new Alea(key));
|
|
mod_noise_3d.set(key, noise);
|
|
}
|
|
return noise;
|
|
}
|
|
|
|
// what mods add to generation, see "World generation" in MODS.md
|
|
export interface WorldgenSetup {
|
|
ores: OreJson[];
|
|
features: { id: string; generate: (chunk: FeatureChunk) => void }[];
|
|
}
|
|
|
|
export interface RawChunk {
|
|
// numeric block ids, only what this chunk generated itself
|
|
blocks: Uint32Array;
|
|
// blocks it generated in other chunks (tree leaves), flattened as x, y, z, numeric id
|
|
spills: Int32Array;
|
|
}
|
|
|
|
// features that threw, so each is only reported once
|
|
const failed_features = new Set<string>();
|
|
|
|
// generates one chunk on its own. neighbors' spills get merged in by whoever assembles the world:
|
|
// a chunk's own blocks always win and spills only fill air, so the result doesn't depend on load order
|
|
export function generate_raw_chunk(
|
|
chunk_x: number,
|
|
chunk_z: number,
|
|
seed: string,
|
|
block_ids: Record<string, number>,
|
|
worldgen?: WorldgenSetup,
|
|
): RawChunk {
|
|
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
|
|
const spills: number[] = [];
|
|
const heights = new Int32Array(CHUNK_AREA);
|
|
const biomes: string[] = new Array(CHUNK_AREA);
|
|
|
|
const set = (x: number, y: number, z: number, nid: number) => {
|
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
|
return;
|
|
}
|
|
const block_chunk_x = Math.floor(x / CHUNK_SIZE);
|
|
const block_chunk_z = Math.floor(z / CHUNK_SIZE);
|
|
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
|
|
spills.push(x, y, z, nid);
|
|
return;
|
|
}
|
|
const lx = x - chunk_x * CHUNK_SIZE;
|
|
const lz = z - chunk_z * CHUNK_SIZE;
|
|
blocks[y * CHUNK_AREA + lz * CHUNK_SIZE + lx] = nid;
|
|
};
|
|
|
|
generate_chunk(
|
|
{
|
|
add_block(block) {
|
|
const nid = block_ids[block.id];
|
|
if (nid !== undefined) {
|
|
set(block.x, block.y, block.z, nid);
|
|
}
|
|
},
|
|
set_column(x, z, height, biome) {
|
|
const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE);
|
|
heights[index] = height;
|
|
biomes[index] = biome;
|
|
},
|
|
},
|
|
chunk_x,
|
|
chunk_z,
|
|
seed,
|
|
);
|
|
|
|
if (worldgen) {
|
|
generate_ores(blocks, chunk_x, chunk_z, seed, block_ids, worldgen.ores);
|
|
generate_features(blocks, heights, biomes, set, chunk_x, chunk_z, seed, block_ids, worldgen.features);
|
|
}
|
|
|
|
return { blocks, spills: new Int32Array(spills) };
|
|
}
|
|
|
|
// each block an ore replaces becomes the first ore whose noise is above its threshold
|
|
function generate_ores(
|
|
blocks: Uint32Array,
|
|
chunk_x: number,
|
|
chunk_z: number,
|
|
seed: string,
|
|
block_ids: Record<string, number>,
|
|
ores: OreJson[],
|
|
) {
|
|
const usable = ores
|
|
.map((ore) => ({ ...ore, nid: block_ids[ore.id], replaces_nid: block_ids[ore.replaces] }))
|
|
.filter((ore) => ore.nid !== undefined && ore.replaces_nid !== undefined);
|
|
if (usable.length === 0) {
|
|
return;
|
|
}
|
|
const noises = usable.map((ore) => named_noise_3d(seed, ore.id));
|
|
|
|
for (let y = 0; y < CHUNK_HEIGHT; y++) {
|
|
for (let lz = 0; lz < CHUNK_SIZE; lz++) {
|
|
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
|
const index = y * CHUNK_AREA + lz * CHUNK_SIZE + lx;
|
|
const current = blocks[index];
|
|
if (current === AIR) {
|
|
continue;
|
|
}
|
|
for (let i = 0; i < usable.length; i++) {
|
|
const ore = usable[i];
|
|
if (current !== ore.replaces_nid || y < ore.min_y || y > ore.max_y) {
|
|
continue;
|
|
}
|
|
const wx = chunk_x * CHUNK_SIZE + lx;
|
|
const wz = chunk_z * CHUNK_SIZE + lz;
|
|
if (noises[i](wx * ore.scale, y * ore.scale, wz * ore.scale) > ore.threshold) {
|
|
blocks[index] = ore.nid;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function generate_features(
|
|
blocks: Uint32Array,
|
|
heights: Int32Array,
|
|
biomes: string[],
|
|
set: (x: number, y: number, z: number, nid: number) => void,
|
|
chunk_x: number,
|
|
chunk_z: number,
|
|
seed: string,
|
|
block_ids: Record<string, number>,
|
|
features: WorldgenSetup["features"],
|
|
) {
|
|
const ids_by_nid: string[] = [];
|
|
for (const [id, nid] of Object.entries(block_ids)) {
|
|
ids_by_nid[nid] = id;
|
|
}
|
|
|
|
const local = (x: number, z: number, what: string) => {
|
|
const lx = x - chunk_x * CHUNK_SIZE;
|
|
const lz = z - chunk_z * CHUNK_SIZE;
|
|
if (lx < 0 || lx >= CHUNK_SIZE || lz < 0 || lz >= CHUNK_SIZE) {
|
|
throw new Error(`${what}(${x}, ${z}) is outside chunk ${chunk_x}, ${chunk_z}`);
|
|
}
|
|
return lz * CHUNK_SIZE + lx;
|
|
};
|
|
|
|
for (const feature of features) {
|
|
const chunk: FeatureChunk = {
|
|
x: chunk_x,
|
|
z: chunk_z,
|
|
seed,
|
|
rng: new Alea(`${seed}_feature_${feature.id}_${chunk_x}_${chunk_z}`),
|
|
noise_2d: (name) => named_noise_2d(seed, name),
|
|
noise_3d: (name) => named_noise_3d(seed, name),
|
|
height_at: (x, z) => heights[local(x, z, "height_at")],
|
|
biome_at: (x, z) => biomes[local(x, z, "biome_at")],
|
|
get_block(x, y, z) {
|
|
if (y < 0 || y >= CHUNK_HEIGHT) {
|
|
return undefined;
|
|
}
|
|
const nid = blocks[y * CHUNK_AREA + local(x, z, "get_block")];
|
|
return nid === AIR ? "bworld:air" : ids_by_nid[nid];
|
|
},
|
|
set_block(x, y, z, id) {
|
|
const nid = id === "bworld:air" ? AIR : block_ids[id];
|
|
if (nid === undefined) {
|
|
throw new Error(`unknown block ${id}`);
|
|
}
|
|
const dx = Math.floor(x / CHUNK_SIZE) - chunk_x;
|
|
const dz = Math.floor(z / CHUNK_SIZE) - chunk_z;
|
|
if (Math.abs(dx) > 1 || Math.abs(dz) > 1) {
|
|
throw new Error(`set_block(${x}, ${y}, ${z}) is more than one chunk away`);
|
|
}
|
|
set(x, y, z, nid);
|
|
},
|
|
};
|
|
try {
|
|
feature.generate(chunk);
|
|
} catch (e) {
|
|
// every client and the server hit the same error, so skipping it keeps them in agreement
|
|
if (!failed_features.has(feature.id)) {
|
|
failed_features.add(feature.id);
|
|
console.error(`Worldgen feature ${feature.id} failed, skipping it where it throws:`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|