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
+3
View File
@@ -6,6 +6,7 @@ import { AIR, CHUNK_AREA, CHUNK_HEIGHT, CHUNK_SIZE, Faces, ID_MASK, VOID } from
import { AssetManager } from "../assets.ts";
import { ClientWorld } from "../client_world.ts";
import { ChunkWorkerPool } from "../chunk_workers.ts";
import { worldgen_mods } from "../mods.ts";
import type { FromChunkWorker } from "../workers/chunk_messages.ts";
import { create_vertex_buffer, destroy_vertex_buffer, Texture } from "../renderer/mod.ts";
import { Camera } from "./camera.ts";
@@ -69,6 +70,8 @@ export class Dimension extends Component {
block_ids,
textures_info: AssetManager.instance.get("bworld:textures_info"),
image: { width: this.image.width, height: this.image.height },
worldgen_scripts: worldgen_mods.scripts,
ores: worldgen_mods.ores,
});
}
+16 -4
View File
@@ -4,9 +4,7 @@ import { InputManager } from "./input_manager.ts";
import { Connection, get_player_name, get_server_url } from "./network.ts";
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
import { is_stopped, show_fatal_error } from "./fatal.ts";
await import("$/common/blocks/mod.ts");
await import("$/common/items/mod.ts");
import { load_client_mods, set_mods_world } from "./mods.ts";
export class ClientLoop {
running = false;
@@ -94,16 +92,30 @@ init_font();
// the game only runs against a server, it owns the world and everything in it
const connection = await Connection.open(get_server_url(), get_player_name());
let mods_loaded = false;
if (connection) {
console.log(`Connected to ${get_server_url()}`);
try {
// blocks, items and textures all come from mods, so this happens before anything else
await load_client_mods(connection.mods);
console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
mods_loaded = true;
} catch (e) {
console.error(e);
show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`);
connection.socket.close();
}
}
if (connection && mods_loaded) {
const client_world = new ClientWorld(connection);
set_mods_world(client_world);
client_world.add_chat("Connected to the server");
const loop = new ClientLoop(client_world);
loop.start();
console.log("Game started");
} else {
} else if (!connection) {
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
}
+87
View File
@@ -0,0 +1,87 @@
// loads the mods the server lists: registers their data, then runs their client scripts.
// see "Delivery to clients" in MODS.md
import { AIR } from "$/common/constants.ts";
import { BlockRegistry, EverythingRegistry } from "$/common/everything_registry.ts";
import type { ClientContext } from "$/common/mod_api/client.ts";
import { ModData, ModListing, ModLoadError, register_mod_data } from "$/common/mod_loader.ts";
import type { OreJson } from "$/common/mod_data.ts";
import { AIR_ID } from "$/common/protocol.ts";
import { Position } from "$/common/components/position.ts";
import type { ClientWorld } from "./client_world.ts";
// what the chunk workers need to generate the same world as the server
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
// set once the world exists, mods can't look at it during setup
let world: ClientWorld | undefined;
export function set_mods_world(client_world: ClientWorld) {
world = client_world;
}
export async function load_client_mods(listings: ModListing[]) {
const site = new URL("/", location.href);
const datas = await Promise.all(listings.map(async (listing) => {
const response = await fetch(new URL(listing.data, site));
if (!response.ok) {
throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`);
}
return { id: listing.id, data: await response.json() as ModData };
}));
const recipes = register_mod_data(datas);
worldgen_mods.ores = recipes.ores;
worldgen_mods.scripts = listings.flatMap((listing) =>
listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : []
);
for (const listing of listings) {
if (!listing.client) continue;
const module = await import(new URL(listing.client, site).href);
if (typeof module.setup !== "function") {
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
}
await module.setup(client_context(listing));
}
}
function client_context(listing: ModListing): ClientContext {
const mod = listing.id;
const not_yet = (name: string, where: string) =>
new Proxy({}, {
get: (_, prop) => () => {
throw new Error(`[${mod}] ctx.${name}.${String(prop)} isn't implemented yet (${where} in MODS.md)`);
},
});
const need_world = () => {
if (!world) throw new Error(`[${mod}] the world isn't there yet during setup`);
return world;
};
return {
mod: { id: mod, version: listing.version },
ui: not_yet("ui", "step 7") as ClientContext["ui"],
hud: not_yet("hud", "step 7") as ClientContext["hud"],
input: not_yet("input", "step 8") as ClientContext["input"],
net: not_yet("net", "step 8") as ClientContext["net"],
player: {
get name() {
return need_world().connection.name;
},
get position() {
const [player] = need_world().get_tag("player")!;
const position = player.get(Position)!;
return { x: position.x, y: position.y, z: position.z };
},
},
world: {
get_block(x, y, z) {
const nid = need_world().dimension.get_block(x, y, z);
if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
},
},
log: (...args) => console.log(`[${mod}]`, ...args),
};
}
+5
View File
@@ -1,4 +1,5 @@
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
import type { ModListing } from "$/common/mod_loader.ts";
const CONNECT_TIMEOUT_MS = 3000;
@@ -13,7 +14,9 @@ export interface RemotePlayer extends PlayerInfo {
export class Connection {
socket: WebSocket;
id: string;
name: string;
seed: string;
mods: ModListing[];
initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
@@ -26,7 +29,9 @@ export class Connection {
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
this.socket = socket;
this.id = welcome.id;
this.name = welcome.name;
this.seed = welcome.seed;
this.mods = welcome.mods;
this.initial_changes = welcome.changes;
this.spawn = welcome.spawn;
this.selected_slot = welcome.selected_slot;
+7
View File
@@ -75,6 +75,13 @@ export class NetworkSystem extends System {
}
break;
}
case "teleport": {
const position = local_player.get(Position)!;
position.x = message.x;
position.y = message.y;
position.z = message.z;
break;
}
case "close_screen":
// closed by the server (the block broke), it already put everything back
player_component.screens = player_component.screens.filter((s) => !(s instanceof GuiContainer));
+1 -1
View File
@@ -102,7 +102,7 @@ export function render_player_breaking(player_component: PlayerComponent) {
if (Number.isNaN(break_sprite)) {
return;
}
const region = get_sprite_region(`bworld:break_${break_sprite}`);
const region = get_sprite_region(`engine:break_${break_sprite}`);
for (const fn of FACE_FUNCTIONS) {
fn(
+4 -4
View File
@@ -231,7 +231,7 @@ export function draw_item(item: ItemStack, x: number, y: number) {
// ey its not a bad name
function draw_item_item(item: ItemStack, item_info: ItemRegistry, x: number, y: number) {
let texture_id = "bworld:missing";
let texture_id = "engine:missing";
if (typeof item_info?.texture_id === "string") {
texture_id = item_info.texture_id;
} else if (typeof item_info?.texture_id === "function") {
@@ -256,9 +256,9 @@ function draw_item_block(_item: ItemStack, item_info: ItemRegistry, x: number, y
if (!block_info) throw new Error(`no textures for ${item_info.block_id}`);
let front_texture = "bworld:missing";
let top_texture = "bworld:missing";
let left_texture = "bworld:missing";
let front_texture = "engine:missing";
let top_texture = "engine:missing";
let left_texture = "engine:missing";
const textures = block_info.textures;
+4
View File
@@ -1,5 +1,6 @@
import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts";
import type { OreJson } from "$/common/mod_data.ts";
// messages between the main thread and the chunk workers
@@ -11,6 +12,9 @@ export type ToChunkWorker =
block_ids: Record<string, number>;
textures_info: Record<string, SpriteRegion>;
image: { width: number; height: number };
// mods' worldgen scripts and ores, so generation matches the server
worldgen_scripts: { mod: string; url: string }[];
ores: OreJson[];
}
| { type: "generate"; chunk_x: number; chunk_z: number; seed: string }
| { type: "mesh"; chunk_x: number; chunk_z: number; version: number; padded_chunk: Uint32Array };
+17 -9
View File
@@ -4,7 +4,8 @@ import type { BlockRegistry } from "$/common/everything_registry.ts";
import type { SpriteRegion } from "$/common/constants.ts";
import type { Texture } from "../renderer/types.ts";
import type { FromChunkWorker, ToChunkWorker } from "./chunk_messages.ts";
import { generate_raw_chunk } from "$/common/generation.ts";
import { generate_raw_chunk, WorldgenSetup } from "$/common/generation.ts";
import { load_worldgen } from "$/common/worldgen_loader.ts";
const pad = 0.5;
@@ -266,8 +267,11 @@ let blocks_registry: BlockRegistry[] = [];
let block_ids: Record<string, number> = {};
let textures_info: TexturesInfo = {};
let image: Texture;
let worldgen: WorldgenSetup | undefined;
// generating has to wait for mods' worldgen scripts, or this worker's terrain wouldn't match the server's
let worldgen_ready: Promise<void> = Promise.resolve();
self.onmessage = (event: MessageEvent<ToChunkWorker>) => {
self.onmessage = async (event: MessageEvent<ToChunkWorker>) => {
const message = event.data;
switch (message.type) {
case "init":
@@ -275,8 +279,12 @@ self.onmessage = (event: MessageEvent<ToChunkWorker>) => {
block_ids = message.block_ids;
textures_info = message.textures_info;
image = message.image as Texture;
worldgen_ready = load_worldgen(message.worldgen_scripts, message.ores).then((setup) => {
worldgen = setup;
});
break;
case "generate":
await worldgen_ready;
generate(message.chunk_x, message.chunk_z, message.seed);
break;
case "mesh": {
@@ -311,7 +319,7 @@ function post(message: FromChunkWorker, transfer: Transferable[]) {
}
function generate(chunk_x: number, chunk_z: number, seed: string) {
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids);
const { blocks, spills } = generate_raw_chunk(chunk_x, chunk_z, seed, block_ids, worldgen);
post({ type: "generated", chunk_x, chunk_z, blocks, spills }, [blocks.buffer, spills.buffer]);
}
@@ -353,12 +361,12 @@ function make_chunk_mesh(
const block_info = blocks_registry[block_nid];
const texture_ids = {
top: "bworld:missing",
bottom: "bworld:missing",
front: "bworld:missing",
back: "bworld:missing",
left: "bworld:missing",
right: "bworld:missing",
top: "engine:missing",
bottom: "engine:missing",
front: "engine:missing",
back: "engine:missing",
left: "engine:missing",
right: "engine:missing",
};
const textures = block_info.textures;