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
+1
View File
@@ -1,3 +1,4 @@
build/
world.json
world.json.tmp
server_mods/
+19 -11
View File
@@ -79,7 +79,8 @@ refers to exists (and that it depends on the mods those come from), and typechec
`common/mod_api/`, which scripts import as `bworld/server`, `bworld/client` and `bworld/worldgen`. Folders in `mods/`
starting with `_` or `.` are ignored.
The game can't load mods yet; see the [Implementation plan](#implementation-plan).
`deno task build` builds every mod in `mods/` and fails if any has errors; `deno task server` then loads them. What
works so far is listed in the [Implementation plan](#implementation-plan).
## Mod layout
@@ -1039,8 +1040,8 @@ Worlds saved before the move must load afterwards with nothing changed:
Each phase ends with every test passing and the game playable.
**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which so far
checks `mods/bworld` against the game and the mod tools.
**Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which covers
loading mods (the base game and the template), their scripts and worldgen, saves, and the mod tools.
- Move the test scripts used while building steps 1–3 into the repo as `deno test` files under `tests/`: server logic
(breaking, placing, crafting, chest, furnace, saves), client and server terrain agreement, click prediction, and the
@@ -1051,13 +1052,13 @@ checks `mods/bworld` against the game and the mod tools.
- Save a world from the current code with chests, a running furnace and some player inventories as
`tests/fixtures/world_v2.json`, with a test that loads it and checks everything is where it was.
**Phase 1: data** (after steps 4 and 5).
**Phase 1: data** (after steps 4 and 5). _Done._
- Create `mods/bworld` with its manifest and credits, and move the 62 textures there. Rename the breaking cracks to
`engine:break_0`–`8` and the fallback to `engine:missing`.
- Generate the block, item and recipe JSON with a script that reads the current registries, instead of writing it by
hand. Hand-copying 18 blocks' fields is how typos get in. Done: `deno task export-bworld` writes them, and
`tests/bworld_mod_test.ts` fails if they drift from the game.
hand. Hand-copying 18 blocks' fields is how typos get in. They were generated this way, checked equal to the
TypeScript definitions, and those were then deleted.
- The loader registers the recipes and `server/game/crafting.ts` matches against them. Behaviors stay in
`server/game/blocks.ts`, still keyed by block id, for now.
- Delete `common/blocks/` and `common/items/`.
@@ -1106,7 +1107,8 @@ checks `mods/bworld` against the game and the mod tools.
## Implementation plan
Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps the game working:
Steps 1–4 are done and 5, 6 and 9 mostly, enough that a mod made from the template loads and runs. Each step keeps the
game working:
1. **Game server core.** Move `client/generation.ts` to `common/` (it already only needs constants and the rng package)
and have the server generate terrain. Move world state, chunks, tile data and player inventories into a game server
@@ -1119,16 +1121,22 @@ Steps 1–3 are done; the mod steps (4 onwards) aren't started. Each step keeps
reach a server, and show a connection error instead.
4. **Mod loader and build.** Discover mods, check manifests, sort by dependencies, turn JSON into registry entries,
build the combined atlas (textures are currently all named `bworld:<file>`), bundle scripts per side, and write
hashed output to `build/mods/` and `server_mods/`.
hashed output to `build/mods/` and `server_mods/`. _Done._
5. **Delivery.** Split `welcome` into `welcome` / `ready` / `join`. Clients download, verify and run mods before
joining. Add the confirm screen for cross-origin servers and CORS headers on the server.
joining. Add the confirm screen for cross-origin servers and CORS headers on the server. _Partly done:_ `welcome`
lists the mods and clients download and run them before creating the world, but the server doesn't wait for `ready`
(its messages just queue up meanwhile), hashes aren't checked, and there's no confirm screen or CORS yet.
6. **Server scripts.** Components, events, commands, system, storage and `ctx.recipes`. Move `FURNACE_RECIPES` and
`FUEL_VALUES` into the recipe registry.
`FUEL_VALUES` into the recipe registry. _Mostly done_ (`server/game/mod_runtime.ts`). Not yet: `on_click` (clients
don't report left clicks on blocks), item `on_use` (there's no item use action), `world.get_state` / `set_state`
(block states aren't synced or saved), and `Container.on_change`. `ctx.containers`, `ctx.ui` and `ctx.net` throw
until steps 7 and 8, and so do the client's `ctx.ui`, `ctx.hud`, `ctx.input` and `ctx.net`.
7. **GUIs.** Forms, then container screens (rebuild chest and furnace with them), then custom screens, the `Graphics`
API (built on the existing renderer and debug UI widgets) and the HUD.
8. **Mod channels** and keybinds.
9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing
before generating anything.
before generating anything. _Done for features and ores._ `register_terrain` throws until phase 3 moves the base
terrain out of the engine.
Steps 1–3 are engine work every multiplayer feature needs, with or without mods. Mods could start with data only (step 4
plus data in step 5) before scripts exist.
+141 -27
View File
@@ -1,11 +1,26 @@
import { copy } from "@std/fs";
import { createCanvas, loadImage } from "@gfx/canvas-wasm";
import { ENGINE_TEXTURE_DIR, load_and_check, load_order, LoadedMod } from "./tools/check_mods.ts";
import type { ModData, ModListing } from "./common/mod_loader.ts";
const BUILD_FOLDER = "build";
// all overridable so tests can build somewhere else
const BUILD_FOLDER = Deno.env.get("BUILD_DIR") ?? "build";
const MODS_FOLDER = Deno.env.get("MODS_DIR") ?? "mods";
// server scripts go here instead of the served build folder, players never get them
const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
function clear_folder() {
Deno.removeSync(BUILD_FOLDER, { recursive: true });
Deno.mkdirSync(BUILD_FOLDER);
// what the server reads at startup, see server/main.ts
export interface ServerModIndex {
mods: { listing: ModListing; server?: string }[];
}
function clear_folder(folder: string) {
try {
Deno.removeSync(folder, { recursive: true });
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
Deno.mkdirSync(folder, { recursive: true });
}
async function build_fonts() {
@@ -59,14 +74,22 @@ function calculate_atlas_size(count: number) {
};
}
async function build_atlas_from_folder(folder: string) {
let sprite_count = 0;
for (const entry of Deno.readDirSync(folder)) {
console.assert(entry.isFile && entry.name.endsWith(".png"));
sprite_count += 1;
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>)
async function build_atlas(mods: LoadedMod[]) {
const textures = new Map<string, string>();
for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) {
if (entry.isFile && entry.name.endsWith(".png")) {
textures.set(`engine:${entry.name.replace(".png", "")}`, `${ENGINE_TEXTURE_DIR}/${entry.name}`);
}
}
for (const mod of mods) {
for (const [id, path] of mod.texture_files) {
textures.set(id, path);
}
}
const atlas = calculate_atlas_size(sprite_count);
// + 1 for the missing texture
const atlas = calculate_atlas_size(textures.size + 1);
const canvas = createCanvas(atlas.size, atlas.size);
const ctx = canvas.getContext("2d");
@@ -80,40 +103,119 @@ async function build_atlas_from_folder(folder: string) {
ctx.fillStyle = "black";
ctx.fillRect(8, 0, 8, 8);
ctx.fillRect(0, 8, 8, 8);
atlas_info["engine:missing"] = { x: 0, y: 0 };
let index = 1;
for (const entry of Deno.readDirSync(folder)) {
const sprite = await loadImage(`${folder}/${entry.name}`);
for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) {
const sprite = await loadImage(path);
const row = Math.floor(index / atlas.sprites_per_side);
const column = index % atlas.sprites_per_side;
ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE);
index += 1;
const id = `bworld:${entry.name.replace(".png", "")}`;
atlas_info[id] = { x: column, y: row };
}
Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer());
Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info));
Deno.writeFileSync(`${BUILD_FOLDER}/assets/sprites/textures.png`, canvas.toBuffer());
Deno.writeTextFileSync(`${BUILD_FOLDER}/assets/sprites/textures.json`, JSON.stringify(atlas_info));
}
async function build_sprites() {
async function build_sprites(mods: LoadedMod[]) {
for (const entry of Deno.readDirSync("assets/sprites")) {
if (entry.name.endsWith(".png")) {
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`);
} else if (entry.isDirectory) {
await build_atlas_from_folder(`assets/sprites/${entry.name}`);
}
}
await build_atlas(mods);
}
async function build_assets() {
async function build_assets(mods: LoadedMod[]) {
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
build_fonts();
await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
await build_sprites();
await build_sprites(mods);
}
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
// the game never runs with some mods missing
function load_mods(): LoadedMod[] {
const mods = load_and_check(MODS_FOLDER);
const problems = mods.flatMap((mod) => mod.report.errors.map((error) => ` ${mod.id}: ${error}`));
if (problems.length > 0) {
throw new Error(`Mods have errors (deno task check-mods for details):\n${problems.join("\n")}`);
}
return load_order(mods);
}
async function bundle_script(path: string, platform: "browser" | "deno"): Promise<string> {
const result = await Deno.bundle({ entrypoints: [path], platform, write: false, minify: false });
if (!result.success || !result.outputFiles?.length) {
throw new Error(`Couldn't bundle ${path}:\n${result.errors.map((e) => e.text).join("\n")}`);
}
return result.outputFiles[0].text();
}
async function short_hash(parts: string[]) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0")));
return [...new Uint8Array(digest)].slice(0, 6).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// build/mods/<id>/<hash>/ gets what players download, server_mods/ the server scripts
async function build_mods(mods: LoadedMod[]) {
clear_folder(SERVER_MODS_FOLDER);
const index: ServerModIndex = { mods: [] };
for (const mod of mods) {
const manifest = mod.manifest as { name: string; version: string; scripts?: Record<string, string> };
const scripts = manifest.scripts ?? {};
const data: ModData = {
blocks: mod.blocks.map((b) => b.json),
items: mod.items.map((i) => i.json),
recipes: mod.recipes.map((r) => r.json),
ores: mod.ores.map((o) => o.json),
};
const data_json = JSON.stringify(data);
const client = scripts.client ? await bundle_script(`${mod.dir}/${scripts.client}`, "browser") : undefined;
const worldgen = scripts.worldgen
? await bundle_script(`${mod.dir}/${scripts.worldgen}`, "browser")
: undefined;
const server = scripts.server ? await bundle_script(`${mod.dir}/${scripts.server}`, "deno") : undefined;
const hash = await short_hash([data_json, client ?? "", worldgen ?? ""]);
const public_dir = `mods/${mod.id}/${hash}`;
Deno.mkdirSync(`${BUILD_FOLDER}/${public_dir}`, { recursive: true });
const listing: ModListing = {
id: mod.id,
name: manifest.name,
version: manifest.version,
hash,
data: `${public_dir}/data.json`,
};
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
if (client) {
listing.client = `${public_dir}/client.js`;
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
}
if (worldgen) {
listing.worldgen = `${public_dir}/worldgen.js`;
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
}
const entry: ServerModIndex["mods"][number] = { listing };
if (server) {
const server_dir = `${SERVER_MODS_FOLDER}/${mod.id}/${await short_hash([server])}`;
Deno.mkdirSync(server_dir, { recursive: true });
entry.server = `${server_dir}/server.js`;
Deno.writeTextFileSync(entry.server, server);
}
index.mods.push(entry);
}
Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t"));
console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`);
}
async function build_client() {
@@ -130,20 +232,32 @@ async function build_client() {
async function build() {
try {
const now = performance.now();
clear_folder();
await build_assets();
clear_folder(BUILD_FOLDER);
const mods = load_mods();
await build_assets(mods);
await build_client();
await build_mods(mods);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (e) {
console.log(e);
// no index means the server refuses to start, instead of running without some mods
try {
Deno.removeSync(`${SERVER_MODS_FOLDER}/index.json`);
} catch { /* wasn't there */ }
console.log(e instanceof Error ? e.message : e);
return false;
}
return true;
}
let last_build = 0;
if (import.meta.main) {
await build();
const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true });
const ok = await build();
// deno task build --once, for scripts and ci
if (Deno.args.includes("--once")) {
Deno.exit(ok ? 0 : 1);
}
const watcher = Deno.watchFs(["assets", "client", "common", MODS_FOLDER], { recursive: true });
for await (const event of watcher) {
const now = performance.now();
if (now - last_build < 500) {
+3
View File
@@ -6,6 +6,7 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from
import { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts";
@@ -69,6 +70,8 @@ export class Dimension extends Component {
block_ids,
textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts,
ores: worldgen_mods.ores,
});
}
+16 -4
View File
@@ -4,9 +4,7 @@ import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name, get_server_url } from "./network.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
await import("$/common/blocks/mod.ts");
await import("$/common/items/mod.ts");
import { load_client_mods, set_mods_world } from "./mods.ts";
export class ClientLoop {
running = false;
@@ -94,16 +92,30 @@ init_font();
// the game only runs against a server, it owns the world and everything in it
const connection = await Connection.open(get_server_url(), get_player_name());
let mods_loaded = false;
if (connection) {
console.log(`Connected to ${get_server_url()}`);
try {
// blocks, items and textures all come from mods, so this happens before anything else
await load_client_mods(connection.mods);
console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
mods_loaded = true;
} catch (e) {
console.error(e);
show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`);
connection.socket.close();
}
}
if (connection && mods_loaded) {
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world);
loop.start();
console.log("Game started");
} else {
} else if (!connection) {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
}
+87
View File
@@ -0,0 +1,87 @@
// loads the mods the server lists: registers their data, then runs their client scripts.
// see "Delivery to clients" in MODS.md
import { AIR } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import type { ClientContext } from "$/common/mod_api/client.ts";
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
// set once the world exists, mods can't look at it during setup
let world: ClientWorld | undefined;
export function set_mods_world(client_world: ClientWorld) {
world = client_world;
}
export async function load_client_mods(listings: ModListing[]) {
const site = new URL("/", location.href);
const datas = await Promise.all(listings.map(async (listing) => {
const response = await fetch(new URL(listing.data, site));
if (!response.ok) {
throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`);
}
return { id: listing.id, data: await response.json() as ModData };
}));
const recipes = register_mod_data(datas);
worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = listings.flatMap((listing) =>
listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : []
);
for (const listing of listings) {
if (!listing.client) continue;
const module = await import(new URL(listing.client, site).href);
if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
}
await module.setup(client_context(listing));
}
}
function client_context(listing: ModListing): ClientContext {
const mod = listing.id;
const not_yet = (name: string, where: string) =>
new Proxy({}, {
get: (_, prop) => () => {
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
},
});
const need_world = () => {
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`);
return world;
};
return {
mod: { id: mod, version: listing.version },
ui: not_yet("ui", "step 7") as ClientContext["ui"],
hud: not_yet("hud", "step 7") as ClientContext["hud"],
input: not_yet("input", "step 8") as ClientContext["input"],
net: not_yet("net", "step 8") as ClientContext["net"],
player: {
get name() {
return need_world().connection.name;
},
get position() {
const [player] = need_world().get_tag("player")!;
const position = player.get(Position)!;
return { x: position.x, y: position.y, z: position.z };
},
},
world: {
get_block(x, y, z) {
const nid = need_world().dimension.get_block(x, y, z);
if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
},
},
log: (...args) => console.log(`[${mod}]`, ...args),
};
}
+5
View File
@@ -1,4 +1,5 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
const CONNECT_TIMEOUT_MS = 3000;
@@ -13,7 +14,9 @@ export interface RemotePlayer extends PlayerInfo {
export class Connection {
socket: WebSocket;
id: string;
name: string;
seed: string;
mods: ModListing[];
initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
@@ -26,7 +29,9 @@ export class Connection {
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket;
this.id = welcome.id;
this.name = welcome.name;
this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = welcome.changes;
this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot;
+7
View File
@@ -75,6 +75,13 @@ export class NetworkSystem extends System {
}
break;
}
case "teleport": {
const position = local_player.get(Position)!;
position.x = message.x;
position.y = message.y;
position.z = message.z;
break;
}
case "close_screen":
// closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
+1 -1
View File
@@ -102,7 +102,7 @@ export function render_player_breaking(player_component: PlayerComponent) {
if (Number.isNaN(break_sprite)) {
return;
}
const region = get_sprite_region(`bworld:break_${break_sprite}`);
const region = get_sprite_region(`engine:break_${break_sprite}`);
for (const fn of FACE_FUNCTIONS) {
fn(
+4 -4
View File
@@ -231,7 +231,7 @@ export function draw_item(item: ItemStack, x: number, y: number) {
// ey its not a bad name
function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) {
let texture_id = "bworld:missing";
let texture_id = "engine:missing";
if (typeof item_info?.texture_id === "string") {
texture_id = item_info.texture_id;
} else if (typeof item_info?.texture_id === "function") {
@@ -256,9 +256,9 @@ function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
let front_texture = "bworld:missing";
let top_texture = "bworld:missing";
let left_texture = "bworld:missing";
let front_texture = "engine:missing";
let top_texture = "engine:missing";
let left_texture = "engine:missing";
const textures = block_info.textures;
+4
View File
@@ -1,5 +1,6 @@
import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts";
import type { OreJson } from "$/common/mod_data.ts";
// messages between the main thread and the chunk workers
@@ -11,6 +12,9 @@ export type ToChunkWorker =
block_ids: Record<string, number>;
textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number };
// mods' worldgen scripts and ores, so generation matches the server
worldgen_scripts: { mod: string; url: string }[];
ores: OreJson[];
}
| { type: "generate"; chunk_x: number; chunk_z: number; seed: string }
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array };
+17 -9
View File
@@ -4,7 +4,8 @@ import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
import { generate_raw_chunk } from "$/common/generation.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
const pad = 0.5;
@@ -266,8 +267,11 @@ let blocks_registry: BlockRegistry[] = [];
let block_ids: Record<string, number> = {};
let textures_info: TexturesInfo = {};
let image: Texture;
let worldgen: WorldgenSetup | undefined;
// generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's
let worldgen_ready: Promise<void> = Promise.resolve();
self.onmessage = (event: MessageEvent<ToChunkWorker>) => {
self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
const message = event.data;
switch (message.type) {
case "init":
@@ -275,8 +279,12 @@ self.onmessage = (event: MessageEvent<ToChunkWorker>) => {
block_ids = message.block_ids;
textures_info = message.textures_info;
image = message.image as Texture;
worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
worldgen = setup;
});
break;
case "generate":
await worldgen_ready;
generate(message.chunk_x, message.chunk_z, message.seed);
break;
case "mesh": {
@@ -311,7 +319,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
}
function generate(chunk_x: number, chunk_z: number, seed: string) {
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids);
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen);
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
}
@@ -353,12 +361,12 @@ function make_chunk_mesh(
const block_info = blocks_registry[block_nid];
const texture_ids = {
top: "bworld:missing",
bottom: "bworld:missing",
front: "bworld:missing",
back: "bworld:missing",
left: "bworld:missing",
right: "bworld:missing",
top: "engine:missing",
bottom: "engine:missing",
front: "engine:missing",
back: "engine:missing",
left: "engine:missing",
right: "engine:missing",
};
const textures = block_info.textures;
-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;
}
+1 -1
View File
@@ -5,7 +5,6 @@
"server": "deno run --unstable-worker-options --allow-net --allow-read --allow-write --allow-env server/main.ts",
"new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
"check-mods": "deno run --allow-read --allow-run tools/check_mods.ts",
"export-bworld": "deno run --allow-read --allow-write --allow-run tools/export_bworld_mod.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/"
},
"compilerOptions": {
@@ -28,6 +27,7 @@
"@std/assert": "jsr:@std/assert@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^1.0.23",
"@std/path": "jsr:@std/path@^1.0.0",
"gl-matrix": "npm:gl-matrix@^3.4.4",
"marked": "npm:marked@^17.0.3"
}
Generated
+8 -2
View File
@@ -17,6 +17,7 @@
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6",
"jsr:@std/path@1": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.17": "1.1.2",
"npm:gl-matrix@^3.4.4": "3.4.4",
@@ -54,7 +55,7 @@
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [
"jsr:@std/internal@^1.0.12",
"jsr:@std/path"
"jsr:@std/path@^1.1.4"
]
},
"@std/html@1.0.5": {
@@ -70,7 +71,7 @@
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path",
"jsr:@std/path@^1.1.4",
"jsr:@std/streams"
]
},
@@ -102,6 +103,10 @@
"bin": true
}
},
"remote": {
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/client.js": "2f5008856e1e7b13d0fe658aefcdea38dea80b4805e41101c5ff6c2d3d258245",
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/worldgen.js": "a3da997bdfee885433de282338bdba388825868f122c9a8759b2f1bd67120c78"
},
"workspace": {
"dependencies": [
"jsr:@gfx/canvas-wasm@~0.4.2",
@@ -109,6 +114,7 @@
"jsr:@std/assert@1",
"jsr:@std/fs@^1.0.23",
"jsr:@std/http@^1.0.23",
"jsr:@std/path@1",
"npm:gl-matrix@^3.4.4",
"npm:marked@^17.0.3"
]
+8 -16
View File
@@ -1,21 +1,13 @@
# bworld
The base game as a mod, following "The base game as a mod" in `MODS.md`.
The base game as a mod, following "The base game as a mod" in `MODS.md`. The game loads its blocks, items, recipes and
textures from here, the same way it loads any other mod.
**The game doesn't load this yet.** Until the mod loader exists, the game still registers everything from
`common/blocks/`, `common/items/`, `server/game/crafting.ts` and `server/game/blocks.ts`. The `blocks/`, `items/` and
`recipes/` folders here are generated from those by:
```sh
deno task export-bworld
```
Run it after changing any of them. `tests/bworld_mod_test.ts` fails when this folder and the game disagree.
Still to move here, by phase:
Still built into the engine, by phase:
| Phase | What |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Textures from `assets/sprites/textures/` (the build still reads them from there). |
| 2 | Block behavior from `server/game/blocks.ts`: `bworld:hoeable`, `bworld:storage`, `bworld:furnace`, and the watering can's `bworld:watering_can` item component. |
| 3 | Terrain, biomes, trees and ores from `common/generation.ts`. |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2 | Block behavior in `server/game/blocks.ts` (hoeing, chest, furnace) and the watering can's starting water. They become the `bworld:hoeable`, `bworld:storage`, `bworld:furnace` and `bworld:watering_can` components. |
| 3 | Terrain, biomes, trees and ores in `common/generation.ts`. |
`bworld:stick` has no texture yet, so sticks show as missing.
+1 -1
View File
@@ -7,6 +7,6 @@
"toughness": 3,
"tool": "axe"
},
"drops": "bworld:log"
"drops": "bworld:planks"
}
}

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 130 B

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 230 B

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 275 B

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 416 B

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

Before

Width:  |  Height:  |  Size: 254 B

After

Width:  |  Height:  |  Size: 254 B

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 312 B

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

Before

Width:  |  Height:  |  Size: 157 B

After

Width:  |  Height:  |  Size: 157 B

Before

Width:  |  Height:  |  Size: 156 B

After

Width:  |  Height:  |  Size: 156 B

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 406 B

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 166 B

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 447 B

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 317 B

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 177 B

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 177 B

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 279 B

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 215 B

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 343 B

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 208 B

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

Before

Width:  |  Height:  |  Size: 370 B

After

Width:  |  Height:  |  Size: 370 B

Before

Width:  |  Height:  |  Size: 215 B

After

Width:  |  Height:  |  Size: 215 B

Before

Width:  |  Height:  |  Size: 205 B

After

Width:  |  Height:  |  Size: 205 B

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 340 B

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 241 B

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 466 B

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 466 B

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 478 B

Some files were not shown because too many files have changed in this diff Show More