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:) and every mod's (:). // named by its hash, so clients can cache it forever and download it from other origins async function build_atlas(mods: LoadedMod[]): Promise { const textures = new Map(); 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 = {}; // 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 { 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 { 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 | 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/// 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 }; 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`, 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); } 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(); } } }