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/ build/
world.json world.json
world.json.tmp 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/` `common/mod_api/`, which scripts import as `bworld/server`, `bworld/client` and `bworld/worldgen`. Folders in `mods/`
starting with `_` or `.` are ignored. 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 ## 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. 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 **Phase 0: safety net.** Do this first, before any other mod work. Started: `deno task test` runs `tests/`, which covers
checks `mods/bworld` against the game and the mod tools. 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 - 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 (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 - 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. `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 - 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`. `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 - 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 hand. Hand-copying 18 blocks' fields is how typos get in. They were generated this way, checked equal to the
`tests/bworld_mod_test.ts` fails if they drift from the game. TypeScript definitions, and those were then deleted.
- The loader registers the recipes and `server/game/crafting.ts` matches against them. Behaviors stay in - 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. `server/game/blocks.ts`, still keyed by block id, for now.
- Delete `common/blocks/` and `common/items/`. - Delete `common/blocks/` and `common/items/`.
@@ -1106,7 +1107,8 @@ checks `mods/bworld` against the game and the mod tools.
## Implementation plan ## 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) 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 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. 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, 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 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 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 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` 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. API (built on the existing renderer and debug UI widgets) and the HUD.
8. **Mod channels** and keybinds. 8. **Mod channels** and keybinds.
9. **Worldgen mods.** Worldgen URLs and mod ores go into the chunk worker `init` message. Workers must finish importing 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 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. plus data in step 5) before scripts exist.
+141 -27
View File
@@ -1,11 +1,26 @@
import { copy } from "@std/fs"; import { copy } from "@std/fs";
import { createCanvas, loadImage } from "@gfx/canvas-wasm"; 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() { // what the server reads at startup, see server/main.ts
Deno.removeSync(BUILD_FOLDER, { recursive: true }); export interface ServerModIndex {
Deno.mkdirSync(BUILD_FOLDER); 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() { async function build_fonts() {
@@ -59,14 +74,22 @@ function calculate_atlas_size(count: number) {
}; };
} }
async function build_atlas_from_folder(folder: string) { // one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>)
let sprite_count = 0; async function build_atlas(mods: LoadedMod[]) {
for (const entry of Deno.readDirSync(folder)) { const textures = new Map<string, string>();
console.assert(entry.isFile && entry.name.endsWith(".png")); for (const entry of Deno.readDirSync(ENGINE_TEXTURE_DIR)) {
sprite_count += 1; 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 canvas = createCanvas(atlas.size, atlas.size);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
@@ -80,40 +103,119 @@ async function build_atlas_from_folder(folder: string) {
ctx.fillStyle = "black"; ctx.fillStyle = "black";
ctx.fillRect(8, 0, 8, 8); ctx.fillRect(8, 0, 8, 8);
ctx.fillRect(0, 8, 8, 8); ctx.fillRect(0, 8, 8, 8);
atlas_info["engine:missing"] = { x: 0, y: 0 };
let index = 1; let index = 1;
for (const entry of Deno.readDirSync(folder)) { for (const [id, path] of [...textures].sort(([a], [b]) => a.localeCompare(b))) {
const sprite = await loadImage(`${folder}/${entry.name}`); const sprite = await loadImage(path);
const row = Math.floor(index / atlas.sprites_per_side); const row = Math.floor(index / atlas.sprites_per_side);
const column = index % atlas.sprites_per_side; const column = index % atlas.sprites_per_side;
ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE); ctx.drawImage(sprite, column * SPRITE_SIZE, row * SPRITE_SIZE);
index += 1; index += 1;
const id = `bworld:${entry.name.replace(".png", "")}`;
atlas_info[id] = { x: column, y: row }; atlas_info[id] = { x: column, y: row };
} }
Deno.writeFileSync(`${BUILD_FOLDER}/${folder}.png`, canvas.toBuffer()); Deno.writeFileSync(`${BUILD_FOLDER}/assets/sprites/textures.png`, canvas.toBuffer());
Deno.writeTextFileSync(`${BUILD_FOLDER}/${folder}.json`, JSON.stringify(atlas_info)); 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")) { for (const entry of Deno.readDirSync("assets/sprites")) {
if (entry.name.endsWith(".png")) { if (entry.name.endsWith(".png")) {
await copy(`assets/sprites/${entry.name}`, `${BUILD_FOLDER}/assets/sprites/${entry.name}`); 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 }); Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`); await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
build_fonts(); await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true }); 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() { async function build_client() {
@@ -130,20 +232,32 @@ async function build_client() {
async function build() { async function build() {
try { try {
const now = performance.now(); const now = performance.now();
clear_folder(); clear_folder(BUILD_FOLDER);
await build_assets(); const mods = load_mods();
await build_assets(mods);
await build_client(); await build_client();
await build_mods(mods);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`); console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (e) { } 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; let last_build = 0;
if (import.meta.main) { if (import.meta.main) {
await build(); const ok = await build();
const watcher = Deno.watchFs(["assets", "client", "common"], { recursive: true }); // 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) { for await (const event of watcher) {
const now = performance.now(); const now = performance.now();
if (now - last_build < 500) { 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 { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts"; import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts"; import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.ts"; import type { FromChunkWorker } from "../workers/chunk_messages.ts";
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts"; import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts"; import { Camera } from "./camera.ts";
@@ -69,6 +70,8 @@ export class Dimension extends Component {
block_ids, block_ids,
textures_info: AssetManager.instance.get("bworld:textures_info"), textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height }, 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 { 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 { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts"; import { is_stopped, show_fatal_error } from "./fatal.ts";
import { load_client_mods, set_mods_world } from "./mods.ts";
await import("$/common/blocks/mod.ts");
await import("$/common/items/mod.ts");
export class ClientLoop { export class ClientLoop {
running = false; running = false;
@@ -94,16 +92,30 @@ init_font();
// the game only runs against a server, it owns the world and everything in it // 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()); const connection = await Connection.open(get_server_url(), get_player_name());
let mods_loaded = false;
if (connection) { if (connection) {
console.log(`Connected to ${get_server_url()}`); 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); const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server"); client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world); const loop = new ClientLoop(client_world);
loop.start(); loop.start();
console.log("Game started"); console.log("Game started");
} else { } else if (!connection) {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`); 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 { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
const CONNECT_TIMEOUT_MS = 3000; const CONNECT_TIMEOUT_MS = 3000;
@@ -13,7 +14,9 @@ export interface RemotePlayer extends PlayerInfo {
export class Connection { export class Connection {
socket: WebSocket; socket: WebSocket;
id: string; id: string;
name: string;
seed: string; seed: string;
mods: ModListing[];
initial_changes: BlockChange[]; initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number; selected_slot: number;
@@ -26,7 +29,9 @@ export class Connection {
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) { constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket; this.socket = socket;
this.id = welcome.id; this.id = welcome.id;
this.name = welcome.name;
this.seed = welcome.seed; this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = welcome.changes; this.initial_changes = welcome.changes;
this.spawn = welcome.spawn; this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot; this.selected_slot = welcome.selected_slot;
+7
View File
@@ -75,6 +75,13 @@ export class NetworkSystem extends System {
} }
break; 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": case "close_screen":
// closed by the server (the block broke), it already put everything back // closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer)); 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)) { if (Number.isNaN(break_sprite)) {
return; 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) { for (const fn of FACE_FUNCTIONS) {
fn( 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 // ey its not a bad name
function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) { 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") { if (typeof item_info?.texture_id === "string") {
texture_id = item_info.texture_id; texture_id = item_info.texture_id;
} else if (typeof item_info?.texture_id === "function") { } 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}`); if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
let front_texture = "bworld:missing"; let front_texture = "engine:missing";
let top_texture = "bworld:missing"; let top_texture = "engine:missing";
let left_texture = "bworld:missing"; let left_texture = "engine:missing";
const textures = block_info.textures; const textures = block_info.textures;
+4
View File
@@ -1,5 +1,6 @@
import type { BlockRegistry } from "$/common/everything_registry.ts"; import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.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 // messages between the main thread and the chunk workers
@@ -11,6 +12,9 @@ export type ToChunkWorker =
block_ids: Record<string, number>; block_ids: Record<string, number>;
textures_info: Record<string, SpriteRegion>; textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number }; 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: "generate"; chunk_x: number; chunk_z: number; seed: string }
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array }; | { 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 { SpriteRegion } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts"; import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.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; const pad = 0.5;
@@ -266,8 +267,11 @@ let blocks_registry: BlockRegistry[] = [];
let block_ids: Record<string, number> = {}; let block_ids: Record<string, number> = {};
let textures_info: TexturesInfo = {}; let textures_info: TexturesInfo = {};
let image: Texture; 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; const message = event.data;
switch (message.type) { switch (message.type) {
case "init": case "init":
@@ -275,8 +279,12 @@ self.onmessage = (event: MessageEvent<ToChunkWorker>) => {
block_ids = message.block_ids; block_ids = message.block_ids;
textures_info = message.textures_info; textures_info = message.textures_info;
image = message.image as Texture; image = message.image as Texture;
worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
worldgen = setup;
});
break; break;
case "generate": case "generate":
await worldgen_ready;
generate(message.chunk_x, message.chunk_z, message.seed); generate(message.chunk_x, message.chunk_z, message.seed);
break; break;
case "mesh": { case "mesh": {
@@ -311,7 +319,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
} }
function generate(chunk_x: number, chunk_z: number, seed: string) { 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]); 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 block_info = blocks_registry[block_nid];
const texture_ids = { const texture_ids = {
top: "bworld:missing", top: "engine:missing",
bottom: "bworld:missing", bottom: "engine:missing",
front: "bworld:missing", front: "engine:missing",
back: "bworld:missing", back: "engine:missing",
left: "bworld:missing", left: "engine:missing",
right: "bworld:missing", right: "engine:missing",
}; };
const textures = block_info.textures; 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 #id_to_value = new Map<string, unknown[]>();
static register<T>(registry: string, key: string, value: T): T { 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)) { if (!this.#key_to_id.has(registry)) {
this.#key_to_id.set(registry, new Map()); this.#key_to_id.set(registry, new Map());
this.#id_to_value.set(registry, []); this.#id_to_value.set(registry, []);
@@ -49,6 +52,12 @@ export class EverythingRegistry {
return this.#id_to_value.get(registry) as T[]; 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 // [key, value] pairs in registration order
static entries<T>(registry: string): [string, T][] { static entries<T>(registry: string): [string, T][] {
const values = this.#id_to_value.get(registry) ?? []; const values = this.#id_to_value.get(registry) ?? [];
@@ -104,6 +113,8 @@ export interface BlockRegistry {
interactive?: boolean; interactive?: boolean;
// placing a block into it replaces it, like water // placing a block into it replaces it, like water
replaceable?: boolean; replaceable?: boolean;
// custom components from mods and their params, the server runs them
components?: Record<string, unknown>;
compiled_states?: CompiledStateDefinition[]; compiled_states?: CompiledStateDefinition[];
} }
@@ -112,6 +123,9 @@ export interface ItemRegistry<T = unknown | undefined> {
texture_id: string | ((item: ItemStack<T>) => string); texture_id: string | ((item: ItemStack<T>) => string);
block_id?: string; block_id?: string;
tool_type?: string; tool_type?: string;
max_stack?: number;
lore?: string;
components?: Record<string, unknown>;
on_create?(item: ItemStack<T>): void; 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 { 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 // generation runs in a worker now, so it only needs somewhere to put blocks
export interface BlockSink { export interface BlockSink {
add_block(block: { x: number; y: number; z: number; id: string }): void; 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 = 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 height = get_terrain_height(base_height, biome, wx, wz, height_noise);
const surface_block = get_surface_block(biome); const surface_block = get_surface_block(biome);
dimension.set_column?.(wx, wz, height, `bworld:${biome}`);
for (let y = 0; y <= height; y++) { for (let y = 0; y <= height; y++) {
let block = "bworld:stone"; 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 { export interface RawChunk {
// numeric block ids, only what this chunk generated itself // numeric block ids, only what this chunk generated itself
blocks: Uint32Array; blocks: Uint32Array;
@@ -277,6 +312,9 @@ export interface RawChunk {
spills: Int32Array; 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: // 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 // 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( export function generate_raw_chunk(
@@ -284,26 +322,40 @@ export function generate_raw_chunk(
chunk_z: number, chunk_z: number,
seed: string, seed: string,
block_ids: Record<string, number>, block_ids: Record<string, number>,
worldgen?: WorldgenSetup,
): RawChunk { ): RawChunk {
const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT); const blocks = new Uint32Array(CHUNK_SIZE * CHUNK_SIZE * CHUNK_HEIGHT);
const spills: number[] = []; 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( generate_chunk(
{ {
add_block(block) { add_block(block) {
const nid = block_ids[block.id]; const nid = block_ids[block.id];
if (nid === undefined || block.y < 0 || block.y >= CHUNK_HEIGHT) { if (nid !== undefined) {
return; 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); set_column(x, z, height, biome) {
if (block_chunk_x !== chunk_x || block_chunk_z !== chunk_z) { const index = (z - chunk_z * CHUNK_SIZE) * CHUNK_SIZE + (x - chunk_x * CHUNK_SIZE);
spills.push(block.x, block.y, block.z, nid); heights[index] = height;
return; biomes[index] = biome;
}
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;
}, },
}, },
chunk_x, chunk_x,
@@ -311,5 +363,119 @@ export function generate_raw_chunk(
seed, 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) }; 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; max_amount: number;
data?: T; 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.type_id = type_id;
this.amount = amount; this.amount = amount;
this.max_amount = max_amount; this.max_amount = max_amount ?? item_info?.max_stack ?? 64;
this.data = undefined; this.data = undefined;
const item_info = EverythingRegistry.get<ItemRegistry>("items", type_id);
if (item_info?.on_create) { if (item_info?.on_create) {
item_info.on_create(this); 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 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"; import type { BlockRegistry, BlockStateDefinition, ItemRegistry } from "./everything_registry.ts";
export const FORMAT_VERSION = 1; export const FORMAT_VERSION = 1;
@@ -37,6 +37,15 @@ export interface BlockJson {
components?: Record<string, unknown>; components?: Record<string, unknown>;
} }
export interface OreJson {
id: string;
replaces: string;
min_y: number;
max_y: number;
scale: number;
threshold: number;
}
export interface ItemJson { export interface ItemJson {
id: string; id: string;
texture: 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.interactive) json.interactive = true;
if (block.replaceable) json.replaceable = true; if (block.replaceable) json.replaceable = true;
if (block.states) json.states = block.states; if (block.states) json.states = block.states;
if (block.components) json.components = block.components;
return json; 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.interactive) block.interactive = true;
if (json.replaceable) block.replaceable = true; if (json.replaceable) block.replaceable = true;
if (json.states) block.states = json.states; if (json.states) block.states = json.states;
if (json.components) block.components = json.components;
return { block, has_item: json.item ?? true }; 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 }; const json: ItemJson = { id, texture: item.texture_id };
if (item.tool_type !== undefined) json.tool = item.tool_type; if (item.tool_type !== undefined) json.tool = item.tool_type;
if (item.block_id !== undefined) json.places = item.block_id; 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; return json;
} }
@@ -121,6 +135,9 @@ export function item_from_json(json: ItemJson): ItemRegistry {
const item: ItemRegistry = { texture_id: json.texture }; const item: ItemRegistry = { texture_id: json.texture };
if (json.tool !== undefined) item.tool_type = json.tool; if (json.tool !== undefined) item.tool_type = json.tool;
if (json.places !== undefined) item.block_id = json.places; 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; return item;
} }
@@ -303,6 +320,18 @@ export function validate_recipe(json: unknown): Problems {
return 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 { 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"`]; 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 // messages sent between the client and the server, as json over a websocket
import type { Faces } from "./constants.ts"; import type { Faces } from "./constants.ts";
import type { ItemData } from "./inventory.ts"; import type { ItemData } from "./inventory.ts";
import type { ModListing } from "./mod_loader.ts";
export const AIR_ID = "bworld:air"; export const AIR_ID = "bworld:air";
@@ -58,7 +59,11 @@ export type ServerMessage =
| { | {
type: "welcome"; type: "welcome";
id: string; id: string;
// the server may change the name asked for, like alice to alice2
name: string;
seed: string; seed: string;
// in load order, the client loads them before joining the world
mods: ModListing[];
players: PlayerInfo[]; players: PlayerInfo[];
changes: BlockChange[]; changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number }; 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 // 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: "set_block"; x: number; y: number; z: number; id: string }
| { type: "chat"; from?: string; text: string } | { type: "chat"; from?: string; text: string }
| { type: "teleport"; x: number; y: number; z: number }
| { type: "container"; container: ContainerKey; items: (ItemData | null)[] } | { type: "container"; container: ContainerKey; items: (ItemData | null)[] }
| { type: "cursor"; item: ItemData | null } | { type: "cursor"; item: ItemData | null }
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> } | { 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) { export function register_block_item(block: BlockRegistry) {
// TODO: handle block textures // TODO: handle block textures
let texture_id = "bworld:missing"; let texture_id = "engine:missing";
if (typeof block.textures === "string") { if (typeof block.textures === "string") {
texture_id = block.textures; 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", "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", "new-mod": "deno run --allow-read --allow-write tools/new_mod.ts",
"check-mods": "deno run --allow-read --allow-run tools/check_mods.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/" "test": "deno test --allow-read --allow-write --allow-run tests/"
}, },
"compilerOptions": { "compilerOptions": {
@@ -28,6 +27,7 @@
"@std/assert": "jsr:@std/assert@^1.0.0", "@std/assert": "jsr:@std/assert@^1.0.0",
"@std/fs": "jsr:@std/fs@^1.0.23", "@std/fs": "jsr:@std/fs@^1.0.23",
"@std/http": "jsr:@std/http@^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", "gl-matrix": "npm:gl-matrix@^3.4.4",
"marked": "npm:marked@^17.0.3" "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/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6", "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/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.17": "1.1.2", "jsr:@std/streams@^1.0.17": "1.1.2",
"npm:gl-matrix@^3.4.4": "3.4.4", "npm:gl-matrix@^3.4.4": "3.4.4",
@@ -54,7 +55,7 @@
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37",
"dependencies": [ "dependencies": [
"jsr:@std/internal@^1.0.12", "jsr:@std/internal@^1.0.12",
"jsr:@std/path" "jsr:@std/path@^1.1.4"
] ]
}, },
"@std/html@1.0.5": { "@std/html@1.0.5": {
@@ -70,7 +71,7 @@
"jsr:@std/html", "jsr:@std/html",
"jsr:@std/media-types", "jsr:@std/media-types",
"jsr:@std/net", "jsr:@std/net",
"jsr:@std/path", "jsr:@std/path@^1.1.4",
"jsr:@std/streams" "jsr:@std/streams"
] ]
}, },
@@ -102,6 +103,10 @@
"bin": true "bin": true
} }
}, },
"remote": {
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/client.js": "2f5008856e1e7b13d0fe658aefcdea38dea80b4805e41101c5ff6c2d3d258245",
"http://localhost:8768/mods/copper_tools/33af7e7e9e94/worldgen.js": "a3da997bdfee885433de282338bdba388825868f122c9a8759b2f1bd67120c78"
},
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@gfx/canvas-wasm@~0.4.2", "jsr:@gfx/canvas-wasm@~0.4.2",
@@ -109,6 +114,7 @@
"jsr:@std/assert@1", "jsr:@std/assert@1",
"jsr:@std/fs@^1.0.23", "jsr:@std/fs@^1.0.23",
"jsr:@std/http@^1.0.23", "jsr:@std/http@^1.0.23",
"jsr:@std/path@1",
"npm:gl-matrix@^3.4.4", "npm:gl-matrix@^3.4.4",
"npm:marked@^17.0.3" "npm:marked@^17.0.3"
] ]
+8 -16
View File
@@ -1,21 +1,13 @@
# bworld # 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 Still built into the engine, by phase:
`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:
| Phase | What | | Phase | What |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Textures from `assets/sprites/textures/` (the build still reads them from there). | | 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. |
| 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 in `common/generation.ts`. |
| 3 | Terrain, biomes, trees and ores from `common/generation.ts`. |
`bworld:stick` has no texture yet, so sticks show as missing.
+1 -1
View File
@@ -7,6 +7,6 @@
"toughness": 3, "toughness": 3,
"tool": "axe" "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