Implement main game as a mod

This commit is contained in:
2026-09-24 23:49:34 -03:00
parent bb42dd662e
commit 6458bc0440
131 changed files with 1810 additions and 786 deletions
+69 -8
View File
@@ -4,10 +4,12 @@ import {
BlockJson,
FORMAT_VERSION,
ItemJson,
OreJson,
RecipeJson,
validate_block,
validate_item,
validate_manifest,
validate_ore,
validate_recipe,
} from "$/common/mod_data.ts";
@@ -17,7 +19,7 @@ export interface ModReport {
warnings: string[];
}
interface LoadedMod {
export interface LoadedMod {
id: string;
dir: string;
report: ModReport;
@@ -25,22 +27,61 @@ interface LoadedMod {
blocks: { file: string; json: BlockJson }[];
items: { file: string; json: ItemJson }[];
recipes: { file: string; json: RecipeJson }[];
ores: { file: string; json: OreJson }[];
textures: string[];
// absolute paths by texture id
texture_files: Map<string, string>;
}
// the base game's textures are still built from assets/ until the loader exists (phase 1 in MODS.md),
// and the build names all of them bworld:<file>
const BASE_TEXTURE_DIR = "assets/sprites/textures";
// the engine's own textures, named engine:<file>
export const ENGINE_TEXTURE_DIR = "assets/sprites/textures";
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
// loads and checks every mod without typechecking, which is slow. the build uses this
export function load_and_check(mods_dir = "mods"): LoadedMod[] {
const mods: LoadedMod[] = [];
for (const entry of safe_read_dir(mods_dir)) {
if (entry.isDirectory && !entry.name.startsWith(".") && !entry.name.startsWith("_")) {
mods.push(load_mod(mods_dir, entry.name));
}
}
check_references(mods);
return mods;
}
// dependencies before the mods that need them, otherwise alphabetical
export function load_order(mods: LoadedMod[]): LoadedMod[] {
const by_id = new Map(mods.map((mod) => [mod.id, mod]));
const ordered: LoadedMod[] = [];
const state = new Map<string, "visiting" | "done">();
const visit = (mod: LoadedMod, path: string[]) => {
if (state.get(mod.id) === "done") return;
if (state.get(mod.id) === "visiting") {
throw new Error(`mods depend on each other in a circle: ${[...path, mod.id].join(" -> ")}`);
}
state.set(mod.id, "visiting");
const dependencies = ((mod.manifest?.dependencies ?? []) as { id: string }[]).map((d) => d.id).sort();
for (const dependency of dependencies) {
const other = by_id.get(dependency);
if (other) visit(other, [...path, mod.id]);
}
state.set(mod.id, "done");
ordered.push(mod);
};
for (const mod of [...mods].sort((a, b) => a.id.localeCompare(b.id))) {
visit(mod, []);
}
return ordered;
}
export async function check_mods(mods_dir = "mods", options = { typecheck: true }): Promise<ModReport[]> {
const mods = load_and_check(mods_dir);
try {
load_order(mods);
} catch (e) {
for (const mod of mods) mod.report.errors.push((e as Error).message);
}
if (options.typecheck) {
for (const mod of mods) {
@@ -60,7 +101,9 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
blocks: [],
items: [],
recipes: [],
ores: [],
textures: [],
texture_files: new Map(),
};
const error = (message: string) => mod.report.errors.push(message);
@@ -107,6 +150,19 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
load_data("items", "item", validate_item, mod.items);
load_data("recipes", "recipe", validate_recipe, mod.recipes);
if (exists(`${dir}/worldgen/ores.json`)) {
const ores = read_json(`${dir}/worldgen/ores.json`, (m) => error(`worldgen/ores.json: ${m}`));
if (!is_object(ores) || ores.format_version !== FORMAT_VERSION || !Array.isArray(ores.ores)) {
error(`worldgen/ores.json: must be { "format_version": ${FORMAT_VERSION}, "ores": [ ... ] }`);
} else {
ores.ores.forEach((ore, i) => {
const problems = validate_ore(ore);
for (const problem of problems) error(`worldgen/ores.json: ore ${i}: ${problem}`);
if (problems.length === 0) mod.ores.push({ file: "worldgen/ores.json", json: ore as OreJson });
});
}
}
// a mod only registers ids in its own namespace
for (const { file, json } of [...mod.blocks, ...mod.items]) {
if (json.id.split(":")[0] !== id) error(`${file}: ${json.id} isn't in this mod's namespace "${id}"`);
@@ -115,6 +171,7 @@ function load_mod(mods_dir: string, id: string): LoadedMod {
for (const file of walk(`${dir}/textures`, ".png")) {
const texture_id = `${id}:${file.replace(/\.png$/, "").replaceAll("/", "_")}`;
mod.textures.push(texture_id);
mod.texture_files.set(texture_id, `${dir}/textures/${file}`);
const size = png_size(`${dir}/textures/${file}`);
if (!size) {
error(`textures/${file}: isn't a png`);
@@ -132,8 +189,8 @@ function check_references(mods: LoadedMod[]) {
const add = (map: Map<string, string[]>, id: string, mod: string) => map.set(id, [...(map.get(id) ?? []), mod]);
const textures = new Set<string>();
for (const file of walk(BASE_TEXTURE_DIR, ".png")) {
textures.add(`bworld:${file.replace(/\.png$/, "")}`);
for (const file of walk(ENGINE_TEXTURE_DIR, ".png")) {
textures.add(`engine:${file.replace(/\.png$/, "")}`);
}
for (const mod of mods) {
@@ -198,6 +255,10 @@ function check_references(mods: LoadedMod[]) {
break;
}
}
for (const { file, json } of mod.ores) {
uses(file, json.id, "block");
uses(file, json.replaces, "block");
}
}
}