Implement main game as a mod
@@ -1,3 +1,4 @@
|
||||
build/
|
||||
world.json
|
||||
world.json.tmp
|
||||
server_mods/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:coal", {
|
||||
texture_id: "bworld:coal",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:copper_ingot", {
|
||||
texture_id: "bworld:copper_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:gold_ingot", {
|
||||
texture_id: "bworld:gold_ingot",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:hoe", {
|
||||
texture_id: "bworld:hoe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:iron_ingot", {
|
||||
texture_id: "bworld:iron_ingot",
|
||||
});
|
||||
@@ -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";
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:pickaxe", {
|
||||
texture_id: "bworld:pickaxe",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:stick", {
|
||||
texture_id: "bworld:stick",
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
|
||||
EverythingRegistry.register<ItemRegistry>("items", "bworld:tin_ingot", {
|
||||
texture_id: "bworld:tin_ingot",
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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"`];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
Still built into the engine, by phase:
|
||||
|
||||
```sh
|
||||
deno task export-bworld
|
||||
```
|
||||
| Phase | What |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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`. |
|
||||
|
||||
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 |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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`. |
|
||||
`bworld:stick` has no texture yet, so sticks show as missing.
|
||||
|
||||
@@ -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 |