301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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";
|
|
|
|
// 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";
|
|
|
|
// 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 });
|
|
} catch (e) {
|
|
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
|
}
|
|
Deno.mkdirSync(folder, { recursive: true });
|
|
}
|
|
|
|
async function build_fonts() {
|
|
// i used https://fonts.varg.dev/ to build the font, its confusing
|
|
const canvas_size = 256;
|
|
|
|
const font = await loadImage("assets/fonts/m6x11.png");
|
|
const canvas = createCanvas(canvas_size, canvas_size);
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
ctx.drawImage(font, 0, 0);
|
|
const image_data = ctx.getImageData(0, 0, canvas_size, canvas_size);
|
|
const data = image_data.data;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i];
|
|
const g = data[i + 1];
|
|
const b = data[i + 2];
|
|
|
|
if (r === 0 && g === 0 && b === 0) {
|
|
// black pixel, alpha = 0
|
|
data[i + 3] = 0;
|
|
} else {
|
|
// force white white
|
|
data[i] = 255;
|
|
data[i + 1] = 255;
|
|
data[i + 2] = 255;
|
|
data[i + 3] = 255;
|
|
}
|
|
}
|
|
ctx.putImageData(image_data, 0, 0);
|
|
|
|
Deno.mkdirSync(`${BUILD_FOLDER}/assets/fonts`);
|
|
Deno.writeFileSync(`${BUILD_FOLDER}/assets/fonts/m6x11.png`, canvas.toBuffer());
|
|
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[]) {
|
|
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> {
|
|
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);
|
|
}
|
|
|
|
// 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 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: [] };
|
|
|
|
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),
|
|
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.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() {
|
|
const _result = await Deno.bundle({
|
|
entrypoints: ["./client/main.ts", "./client/workers/chunk_worker.ts"],
|
|
outputDir: `${BUILD_FOLDER}/client`,
|
|
platform: "browser",
|
|
minify: false,
|
|
keepNames: true,
|
|
});
|
|
await copy("./client/index.html", `${BUILD_FOLDER}/client/index.html`);
|
|
}
|
|
|
|
async function build() {
|
|
try {
|
|
const now = performance.now();
|
|
clear_folder(BUILD_FOLDER);
|
|
const mods = load_mods();
|
|
const atlas = await build_assets(mods);
|
|
await build_client();
|
|
await build_mods(mods, atlas);
|
|
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;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
let last_build = 0;
|
|
|
|
if (import.meta.main) {
|
|
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) {
|
|
continue;
|
|
}
|
|
if (["create", "modify", "rename", "remove"].includes(event.kind)) {
|
|
last_build = now;
|
|
console.log("Rebuilding...");
|
|
await build();
|
|
}
|
|
}
|
|
}
|