New modding system

This commit is contained in:
2026-09-26 17:33:07 -03:00
parent 86d5c39eab
commit 243ff52063
25 changed files with 1089 additions and 512 deletions
+39 -173
View File
@@ -1,20 +1,16 @@
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 { AtlasListing, ModData, ModListing } from "./common/mod_loader.ts";
import { pack_mod } from "./tools/pack_mod.ts";
import { BMOD_EXTENSION } from "./common/bmod.ts";
import { ENGINE_TEXTURES_INDEX } from "./server/host.ts";
// 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
// where the server loads .bmod files from. the build puts every mod in mods/ there, next to any others
const SERVER_MODS_FOLDER = Deno.env.get("SERVER_MODS_DIR") ?? "server_mods";
// what the server reads at startup, see server/main.ts
export interface ServerModIndex {
atlas: AtlasListing;
mods: { listing: ModListing; server?: string }[];
}
function clear_folder(folder: string) {
try {
Deno.removeSync(folder, { recursive: true });
@@ -58,90 +54,36 @@ async function build_fonts() {
await copy("assets/fonts/m6x11.fnt", `${BUILD_FOLDER}/assets/fonts/m6x11.fnt`);
}
function next_power_of_two(value: number): number {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
const SPRITE_SIZE = 16;
function calculate_atlas_size(count: number) {
const raw_sprites_per_side = Math.ceil(Math.sqrt(count));
const raw_size = raw_sprites_per_side * SPRITE_SIZE;
const size = next_power_of_two(raw_size);
return {
sprites_per_side: size / SPRITE_SIZE,
size,
};
}
// one atlas with the engine's textures (engine:<file>) and every mod's (<mod>:<file>).
// named by its hash, so clients can cache it forever and download it from other origins
async function build_atlas(mods: LoadedMod[]): Promise<AtlasListing> {
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);
}
}
// + 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");
const atlas_info: Record<string, { x: number; y: number }> = {};
// purple and black missing texture at x:0 y:0 wow !
ctx.fillStyle = "magenta";
ctx.fillRect(0, 0, 8, 8);
ctx.fillRect(8, 8, 8, 8);
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 [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;
atlas_info[id] = { x: column, y: row };
}
const png = new Uint8Array(canvas.toBuffer());
const json = new TextEncoder().encode(JSON.stringify(atlas_info));
const hashes = { png: await sha256(png), json: await sha256(json) };
const name = `assets/sprites/textures.${(hashes.png + hashes.json).slice(0, 12)}`;
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.png`, png);
Deno.writeFileSync(`${BUILD_FOLDER}/${name}.json`, json);
return { png: `${name}.png`, json: `${name}.json`, sha256: hashes };
}
async function build_sprites(mods: LoadedMod[]) {
async function build_sprites() {
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}`);
}
}
return await build_atlas(mods);
}
async function build_assets(mods: LoadedMod[]): Promise<AtlasListing> {
// the engine's textures (engine:<file>), with a list of them. clients put them in the texture atlas with the mods'
function build_engine_textures() {
const folder = `${BUILD_FOLDER}/assets/textures`;
Deno.mkdirSync(folder, { recursive: true });
const names: string[] = [];
for (const entry of [...Deno.readDirSync(ENGINE_TEXTURE_DIR)].sort((a, b) => a.name.localeCompare(b.name))) {
if (entry.isFile && entry.name.endsWith(".png")) {
Deno.copyFileSync(`${ENGINE_TEXTURE_DIR}/${entry.name}`, `${folder}/${entry.name}`);
names.push(entry.name.replace(/\.png$/, ""));
}
}
Deno.writeTextFileSync(`${BUILD_FOLDER}/${ENGINE_TEXTURES_INDEX}`, JSON.stringify(names));
}
async function build_assets() {
Deno.mkdirSync(`${BUILD_FOLDER}/assets`, { recursive: true });
await copy("assets/ASSETS.md", `${BUILD_FOLDER}/assets/ASSETS.md`);
await build_fonts();
Deno.mkdirSync(`${BUILD_FOLDER}/assets/sprites`, { recursive: true });
return await build_sprites(mods);
await build_sprites();
build_engine_textures();
}
// every mod in mods/, checked and sorted so dependencies load first. a broken mod fails the whole build,
@@ -155,98 +97,25 @@ function load_mods(): LoadedMod[] {
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 sha256(bytes: Uint8Array<ArrayBuffer> | string) {
const data = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
const digest = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
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[], atlas: AtlasListing) {
clear_folder(SERVER_MODS_FOLDER);
const index: ServerModIndex = { atlas, mods: [] };
// every mod in mods/ into server_mods/<id>.bmod. other .bmod files there are left alone, they're other people's mods
async function build_mods(mods: LoadedMod[]) {
Deno.mkdirSync(SERVER_MODS_FOLDER, { recursive: true });
remove_old_format();
for (const mod of mods) {
const manifest = mod.manifest as {
name: string;
version: string;
scripts?: Record<string, string>;
credits?: string;
};
const scripts = manifest.scripts ?? {};
const data: ModData = {
blocks: mod.blocks.map((b) => b.json),
models: mod.models.map((m) => m.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 credits = manifest.credits ? Deno.readTextFileSync(`${mod.dir}/${manifest.credits}`) : undefined;
const hash = await short_hash([data_json, client ?? "", worldgen ?? "", credits ?? ""]);
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`,
sha256: { data: await sha256(data_json) },
};
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.data}`, data_json);
if (client) {
listing.client = `${public_dir}/client.js`;
listing.sha256.client = await sha256(client);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.client}`, client);
}
if (worldgen) {
listing.worldgen = `${public_dir}/worldgen.js`;
listing.sha256.worldgen = await sha256(worldgen);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.worldgen}`, worldgen);
}
if (credits !== undefined) {
listing.credits = `${public_dir}/credits.md`;
listing.sha256.credits = await sha256(credits);
Deno.writeTextFileSync(`${BUILD_FOLDER}/${listing.credits}`, credits);
}
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.writeFileSync(`${SERVER_MODS_FOLDER}/${mod.id}${BMOD_EXTENSION}`, await pack_mod(mod));
}
Deno.writeTextFileSync(`${SERVER_MODS_FOLDER}/index.json`, JSON.stringify(index, null, "\t"));
console.log(`Mods: ${mods.map((m) => m.id).join(", ") || "none"}`);
}
// builds before .bmod files put each mod's server script in a folder here with an index.json
function remove_old_format() {
for (const entry of Deno.readDirSync(SERVER_MODS_FOLDER)) {
if (entry.isDirectory || entry.name === "index.json") {
Deno.removeSync(`${SERVER_MODS_FOLDER}/${entry.name}`, { recursive: true });
}
}
}
async function build_client() {
const _result = await Deno.bundle({
entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"],
@@ -262,16 +131,13 @@ async function build() {
try {
const now = performance.now();
clear_folder(BUILD_FOLDER);
// checked first: a mod with errors stops the build before any .bmod is written
const mods = load_mods();
const atlas = await build_assets(mods);
await build_assets();
await build_client();
await build_mods(mods, atlas);
await build_mods(mods);
console.log(`Built in ${(performance.now() - now).toFixed(2)}ms`);
} catch (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;
}