New modding system
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user