Implement main game as a mod

This commit is contained in:
2026-09-24 23:49:34 -03:00
parent bb42dd662e
commit 6458bc0440
131 changed files with 1810 additions and 786 deletions
-16
View File
@@ -1,16 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// behavior lives in server/game/blocks.ts
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:chest", {
id: "bworld:chest",
textures: "bworld:planks",
toughness: 8,
requires_tool: false,
tool_to_break: "axe",
drop_table: "bworld:chest",
has_collision: false,
interactive: true,
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:coal_ore", {
id: "bworld:coal_ore",
textures: "bworld:stone_coal",
has_collision: true,
drop_table: "bworld:coal_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:copper_ore", {
id: "bworld:copper_ore",
textures: "bworld:stone_copper",
has_collision: true,
drop_table: "bworld:copper_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-15
View File
@@ -1,15 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// hoeing it is handled in server/game/blocks.ts
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:dirt", {
id: "bworld:dirt",
textures: "bworld:dirt",
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
});
register_block_item(block);
-16
View File
@@ -1,16 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// behavior lives in server/game/blocks.ts
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:furnace", {
id: "bworld:furnace",
textures: { front: "bworld:furnace", side: "bworld:stone" },
has_collision: true,
drop_table: "bworld:furnace",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
interactive: true,
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:glass", {
id: "bworld:glass",
textures: "bworld:glass",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:gold_ore", {
id: "bworld:gold_ore",
textures: "bworld:stone_gold",
has_collision: true,
drop_table: "bworld:gold_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-12
View File
@@ -1,12 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
// hoeing it is handled in server/game/blocks.ts
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:grass", {
id: "bworld:grass",
textures: { top: "bworld:grass_top", bottom: "bworld:dirt", "side": "bworld:grass_side" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 2,
requires_tool: false,
tool_to_break: "shovel",
});
-18
View File
@@ -1,18 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
// TODO: watered state
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:hoed_dirt", {
id: "bworld:hoed_dirt",
textures: { side: "bworld:dirt", top: "bworld:hoed_dirt", bottom: "bworld:dirt" },
has_collision: false,
drop_table: "bworld:dirt",
toughness: 5,
requires_tool: false,
tool_to_break: "shovel",
states: [
{ name: "watered", bits: 1, default: 0 },
],
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:iron_ore", {
id: "bworld:iron_ore",
textures: "bworld:stone_iron",
has_collision: true,
drop_table: "bworld:iron_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:leaves", {
id: "bworld:leaves",
textures: "bworld:leaves",
has_collision: true,
transparent: true,
toughness: 3,
requires_tool: false,
tool_to_break: "hoe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:log", {
id: "bworld:log",
textures: { side: "bworld:log_side", top: "bworld:log_top", bottom: "bworld:log_top" },
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-19
View File
@@ -1,19 +0,0 @@
import "./grass.ts";
import "./dirt.ts";
import "./crops.ts";
import "./water.ts";
import "./chest.ts";
import "./furnace.ts";
import "./stone.ts";
import "./log.ts";
import "./sand.ts";
import "./snow.ts";
import "./glass.ts";
import "./hoed_dirt.ts";
import "./leaves.ts";
import "./coal_ore.ts";
import "./copper_ore.ts";
import "./iron_ore.ts";
import "./tin_ore.ts";
import "./gold_ore.ts";
import "./planks.ts";
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:planks", {
id: "bworld:planks",
textures: "bworld:planks",
has_collision: true,
drop_table: "bworld:log",
toughness: 3,
requires_tool: false,
tool_to_break: "axe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:sand", {
id: "bworld:sand",
textures: "bworld:sand",
has_collision: true,
drop_table: "bworld:sand",
toughness: 3,
requires_tool: false,
tool_to_break: "shovel",
});
register_block_item(block);
-13
View File
@@ -1,13 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:snow", {
id: "bworld:snow",
textures: "bworld:snow",
has_collision: true,
toughness: 2,
requires_tool: true,
tool_to_break: "shovel",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:stone", {
id: "bworld:stone",
textures: "bworld:stone",
has_collision: true,
drop_table: "bworld:stone",
toughness: 3,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-14
View File
@@ -1,14 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import { register_block_item } from "$/common/utils.ts";
const block = EverythingRegistry.register<BlockRegistry>("blocks", "bworld:tin_ore", {
id: "bworld:tin_ore",
textures: "bworld:stone_tin",
has_collision: true,
drop_table: "bworld:tin_ore",
toughness: 5,
requires_tool: true,
tool_to_break: "pickaxe",
});
register_block_item(block);
-10
View File
@@ -1,10 +0,0 @@
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<BlockRegistry>("blocks", "bworld:water", {
id: "bworld:water",
textures: "bworld:water",
has_collision: false,
transparent: true,
alpha: 0.8,
replaceable: true,
});
+14
View File
@@ -5,6 +5,9 @@ export class EverythingRegistry {
static #id_to_value = new Map<string, unknown[]>();
static register<T>(registry: string, key: string, value: T): T {
if (this.#key_to_id.get(registry)?.has(key)) {
throw new Error(`${key} is already registered in ${registry}`);
}
if (!this.#key_to_id.has(registry)) {
this.#key_to_id.set(registry, new Map());
this.#id_to_value.set(registry, []);
@@ -49,6 +52,12 @@ export class EverythingRegistry {
return this.#id_to_value.get(registry) as T[];
}
// forget everything, for tests that load mods more than once
static clear() {
this.#key_to_id.clear();
this.#id_to_value.clear();
}
// [key, value] pairs in registration order
static entries<T>(registry: string): [string, T][] {
const values = this.#id_to_value.get(registry) ?? [];
@@ -104,6 +113,8 @@ export interface BlockRegistry {
interactive?: boolean;
// placing a block into it replaces it, like water
replaceable?: boolean;
// custom components from mods and their params, the server runs them
components?: Record<string, unknown>;
compiled_states?: CompiledStateDefinition[];
}
@@ -112,6 +123,9 @@ export interface ItemRegistry<T = unknown | undefined> {
texture_id: string | ((item: ItemStack<T>) => string);
block_id?: string;
tool_type?: string;
max_stack?: number;
lore?: string;
components?: Record<string, unknown>;
on_create?(item: ItemStack<T>): void;
+178 -12
View File
@@ -1,9 +1,13 @@
import { Alea, create_noise_2d, create_noise_3d, NoiseFunction2D, NoiseFunction3D } from "@paulaboks/rng";
import { CHUNK_HEIGHT, CHUNK_SIZE } from "$/common/constants.ts";
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 =
@@ -226,6 +230,7 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see
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";
@@ -270,6 +275,36 @@ export function generate_chunk(dimension: BlockSink, cx: number, cz: number, see
}
}
// 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;
@@ -277,6 +312,9 @@ export interface RawChunk {
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(
@@ -284,26 +322,40 @@ export function generate_raw_chunk(
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 || block.y < 0 || block.y >= CHUNK_HEIGHT) {
return;
if (nid !== undefined) {
set(block.x, block.y, block.z, nid);
}
const block_chunk_x = Math.floor(block.x / CHUNK_SIZE);
const block_chunk_z = Math.floor(block.z / CHUNK_SIZE);
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) {
spills.push(block.x, block.y, block.z, nid);
return;
}
const lx = block.x - chunk_x * CHUNK_SIZE;
const lz = block.z - chunk_z * CHUNK_SIZE;
blocks[block.y * CHUNK_SIZE * CHUNK_SIZE + lz * CHUNK_SIZE + lx] = 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,
@@ -311,5 +363,119 @@ export function generate_raw_chunk(
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);
}
}
}
}
+3 -3
View File
@@ -13,12 +13,12 @@ export class ItemStack<T = unknown | undefined> {
max_amount: number;
data?: T;
constructor(type_id: string | string, amount: number = 1, max_amount: number = 64) {
constructor(type_id: string | string, amount: number = 1, max_amount?: number) {
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
this.type_id = type_id;
this.amount = amount;
this.max_amount = max_amount;
this.max_amount = max_amount ?? item_info?.max_stack ?? 64;
this.data = undefined;
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
if (item_info?.on_create) {
item_info.on_create(this);
}
-6
View File
@@ -1,6 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:axe", {
texture_id: "bworld:axe",
tool_type: "axe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
texture_id: "bworld:coal",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
texture_id: "bworld:copper_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
texture_id: "bworld:gold_ingot",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
texture_id: "bworld:hoe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
texture_id: "bworld:iron_ingot",
});
-11
View File
@@ -1,11 +0,0 @@
import "./watering_can.ts";
import "./axe.ts";
import "./pickaxe.ts";
import "./hoe.ts";
import "./coal.ts";
import "./tin_ingot.ts";
import "./iron_ingot.ts";
import "./copper_ingot.ts";
import "./gold_ingot.ts";
import "./stick.ts";
import "./wood_pickaxe.ts";
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
texture_id: "bworld:pickaxe",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
texture_id: "bworld:stick",
});
-5
View File
@@ -1,5 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
texture_id: "bworld:tin_ingot",
});
-16
View File
@@ -1,16 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
export interface WateringCanData {
water: number;
max_water: number;
}
EverythingRegistry.register<ItemRegistry<WateringCanData>>("items", "bworld:watering_can", {
texture_id: "bworld:watering_can",
on_create(item) {
item.data = { water: 0, max_water: 32 };
},
get_lore(item) {
return `Water: ${item.data?.water}/${item.data?.max_water}`;
},
});
-6
View File
@@ -1,6 +0,0 @@
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
EverythingRegistry.register<ItemRegistry>("items", "bworld:wood_pickaxe", {
texture_id: "bworld:wood_pickaxe",
tool_type: "pickaxe",
});
+30 -1
View File
@@ -1,5 +1,5 @@
// the json formats from MODS.md, and converting them to and from the engine's registry entries.
// the mod loader uses the from_json direction, tools/export_bworld_mod.ts the other one
// the mod loader uses the from_json direction, tests use both to check nothing is lost
import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts";
export const FORMAT_VERSION = 1;
@@ -37,6 +37,15 @@ export interface BlockJson {
components?: Record<string, unknown>;
}
export interface OreJson {
id: string;
replaces: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
}
export interface ItemJson {
id: string;
texture: string;
@@ -82,6 +91,7 @@ export function block_to_json(block: BlockRegistry, has_item: boolean): BlockJso
if (block.interactive) json.interactive = true;
if (block.replaceable) json.replaceable = true;
if (block.states) json.states = block.states;
if (block.components) json.components = block.components;
return json;
}
@@ -102,6 +112,7 @@ export function block_from_json(json: BlockJson): { block: BlockRegistry; has_it
if (json.interactive) block.interactive = true;
if (json.replaceable) block.replaceable = true;
if (json.states) block.states = json.states;
if (json.components) block.components = json.components;
return { block, has_item: json.item ?? true };
}
@@ -114,6 +125,9 @@ export function item_to_json(id: string, item: ItemRegistry): ItemJson {
const json: ItemJson = { id, texture: item.texture_id };
if (item.tool_type !== undefined) json.tool = item.tool_type;
if (item.block_id !== undefined) json.places = item.block_id;
if (item.max_stack !== undefined) json.max_stack = item.max_stack;
if (item.lore !== undefined) json.lore = item.lore;
if (item.components) json.components = item.components;
return json;
}
@@ -121,6 +135,9 @@ export function item_from_json(json: ItemJson): ItemRegistry {
const item: ItemRegistry = { texture_id: json.texture };
if (json.tool !== undefined) item.tool_type = json.tool;
if (json.places !== undefined) item.block_id = json.places;
if (json.max_stack !== undefined) item.max_stack = json.max_stack;
if (json.lore !== undefined) item.lore = json.lore;
if (json.components) item.components = json.components;
return item;
}
@@ -303,6 +320,18 @@ export function validate_recipe(json: unknown): Problems {
return problems;
}
export function validate_ore(json: unknown): Problems {
if (!is_object(json)) return ["ore must be an object"];
const problems = [...validate_id(json.id, "id"), ...validate_id(json.replaces, "replaces")];
for (const key of ["min_y", "max_y", "scale", "threshold"]) {
if (typeof json[key] !== "number") problems.push(`${key} must be a number`);
}
if (typeof json.min_y === "number" && typeof json.max_y === "number" && json.min_y > json.max_y) {
problems.push("min_y must not be above max_y");
}
return problems;
}
function validate_id(value: unknown, field: string): Problems {
return typeof value === "string" && ID_PATTERN.test(value) ? [] : [`${field} must be an id like "my_mod:thing"`];
}
+89
View File
@@ -0,0 +1,89 @@
// registers mods' data, the same way on the server and on clients
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "./everything_registry.ts";
import {
block_from_json,
BlockJson,
grid_recipe_from_json,
GridRecipe,
item_from_json,
ItemJson,
OreJson,
RecipeJson,
} from "./mod_data.ts";
import { register_block_item } from "./utils.ts";
// everything a mod's json files hold, merged into one file by the build (build/mods/<id>/<hash>/data.json)
export interface ModData {
blocks: BlockJson[];
items: ItemJson[];
recipes: RecipeJson[];
ores: OreJson[];
}
// a mod as the server lists it to clients, in load order. paths are relative to the site root
export interface ModListing {
id: string;
name: string;
version: string;
hash: string;
data: string;
client?: string;
worldgen?: string;
}
export class RecipeBook {
shaped: GridRecipe[] = [];
furnace = new Map<string, { output: { id: string; count: number }; cook_time: number }>();
fuel = new Map<string, number>();
ores: OreJson[] = [];
}
export class ModLoadError extends Error {
constructor(mod: string, message: string) {
super(`${mod}: ${message}`);
this.name = "ModLoadError";
}
}
// registers every mod's blocks, items and recipes, in the order given (dependencies first)
export function register_mod_data(mods: { id: string; data: ModData }[]): RecipeBook {
const recipes = new RecipeBook();
for (const { id, data } of mods) {
const fail = (message: string): never => {
throw new ModLoadError(id, message);
};
try {
for (const json of data.blocks) {
const { block, has_item } = block_from_json(json);
EverythingRegistry.register<BlockRegistry>("blocks", block.id, block);
if (has_item) {
register_block_item(block);
}
}
for (const json of data.items) {
EverythingRegistry.register<ItemRegistry>("items", json.id, item_from_json(json));
}
} catch (e) {
fail((e as Error).message);
}
for (const recipe of data.recipes) {
switch (recipe.type) {
case "shaped":
recipes.shaped.push(grid_recipe_from_json(recipe));
break;
case "furnace":
if (recipes.furnace.has(recipe.input)) fail(`two furnace recipes for ${recipe.input}`);
recipes.furnace.set(recipe.input, { output: recipe.output, cook_time: recipe.cook_time });
break;
case "fuel":
recipes.fuel.set(recipe.item, recipe.burn_time);
break;
}
}
recipes.ores.push(...data.ores);
}
return recipes;
}
+6
View File
@@ -1,6 +1,7 @@
// messages sent between the client and the server, as json over a websocket
import type { Faces } from "./constants.ts";
import type { ItemData } from "./inventory.ts";
import type { ModListing } from "./mod_loader.ts";
export const AIR_ID = "bworld:air";
@@ -58,7 +59,11 @@ export type ServerMessage =
| {
type: "welcome";
id: string;
// the server may change the name asked for, like alice to alice2
name: string;
seed: string;
// in load order, the client loads them before joining the world
mods: ModListing[];
players: PlayerInfo[];
changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
@@ -70,6 +75,7 @@ export type ServerMessage =
// also sent to the player who caused it, which corrects anything their client predicted wrong
| { type: "set_block"; x: number; y: number; z: number; id: string }
| { type: "chat"; from?: string; text: string }
| { type: "teleport"; x: number; y: number; z: number }
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
| { type: "cursor"; item: ItemData | null }
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
+1 -1
View File
@@ -39,7 +39,7 @@ export function distance_point_point(ax: number, ay: number, az: number, bx: num
export function register_block_item(block: BlockRegistry) {
// TODO: handle block textures
let texture_id = "bworld:missing";
let texture_id = "engine:missing";
if (typeof block.textures === "string") {
texture_id = block.textures;
}
+37
View File
@@ -0,0 +1,37 @@
// imports mods' worldgen scripts and collects what they register. used by client chunk workers and the game server
import type { WorldgenContext } from "$/common/mod_api/worldgen.ts";
import type { OreJson } from "$/common/mod_data.ts";
import type { WorldgenSetup } from "./generation.ts";
import { ModLoadError } from "./mod_loader.ts";
export async function load_worldgen(scripts: { mod: string; url: string }[], ores: OreJson[]): Promise<WorldgenSetup> {
const setup: WorldgenSetup = { ores, features: [] };
for (const { mod, url } of scripts) {
const ctx: WorldgenContext = {
register_feature(id, generate) {
if (!id.startsWith(`${mod}:`)) {
throw new ModLoadError(mod, `feature ${id} must be in the namespace "${mod}"`);
}
if (setup.features.some((f) => f.id === id)) {
throw new ModLoadError(mod, `feature ${id} is registered twice`);
}
setup.features.push({ id, generate });
},
register_terrain(id) {
throw new ModLoadError(
mod,
`can't register terrain ${id}: the base terrain is still built into the engine (phase 3 in MODS.md)`,
);
},
};
const module = await import(url);
if (typeof module.setup !== "function") {
throw new ModLoadError(mod, "the worldgen script doesn't export a setup function");
}
await module.setup(ctx);
}
return setup;
}