Implement main game as a mod
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user