Implement main game as a mod
This commit is contained in:
+69
-8
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// deno task export-bworld
|
||||
// writes the base game's blocks, items and recipes into mods/bworld as json, from what the game registers now.
|
||||
// the game still loads the typescript definitions until the mod loader exists (phase 1 in MODS.md),
|
||||
// so run this again after changing them. tests/bworld_mod_test.ts fails when the two drift apart
|
||||
import "$/common/blocks/mod.ts";
|
||||
import "$/common/items/mod.ts";
|
||||
import { BlockRegistry, EverythingRegistry, ItemRegistry } from "$/common/everything_registry.ts";
|
||||
import { block_to_json, grid_recipe_to_json, item_to_json, RecipeJson } from "$/common/mod_data.ts";
|
||||
import { CRAFTING_RECIPES } from "$/server/game/crafting.ts";
|
||||
import { FUEL_VALUES, FURNACE_RECIPES } from "$/server/game/blocks.ts";
|
||||
|
||||
const MOD_DIR = "mods/bworld";
|
||||
const DATA_FOLDERS = ["blocks", "items", "recipes"];
|
||||
|
||||
function name_of(id: string) {
|
||||
return id.split(":")[1];
|
||||
}
|
||||
|
||||
// everything the base game registers, as the json files mods/bworld should contain
|
||||
export function bworld_data_files(): { path: string; content: unknown }[] {
|
||||
const files: { path: string; content: unknown }[] = [];
|
||||
const add = (folder: string, name: string, key: string, content: unknown) =>
|
||||
files.push({ path: `${folder}/${name}.json`, content: { format_version: 1, [key]: content } });
|
||||
|
||||
const items = new Map(EverythingRegistry.entries<ItemRegistry>("items"));
|
||||
|
||||
for (const [id, block] of EverythingRegistry.entries<BlockRegistry>("blocks")) {
|
||||
const has_item = items.get(id)?.block_id === id;
|
||||
add("blocks", name_of(id), "block", block_to_json(block, has_item));
|
||||
}
|
||||
|
||||
for (const [id, item] of items) {
|
||||
// block items come from their block's "item" field
|
||||
if (item.block_id === undefined) {
|
||||
add("items", name_of(id), "item", item_to_json(id, item));
|
||||
}
|
||||
}
|
||||
|
||||
for (const recipe of CRAFTING_RECIPES) {
|
||||
add("recipes", `crafting_${name_of(recipe.result.id)}`, "recipe", grid_recipe_to_json(recipe));
|
||||
}
|
||||
for (const recipe of FURNACE_RECIPES) {
|
||||
add(
|
||||
"recipes",
|
||||
`smelting_${name_of(recipe.input)}`,
|
||||
"recipe",
|
||||
{
|
||||
type: "furnace",
|
||||
input: recipe.input,
|
||||
output: { id: recipe.output.type_id, count: recipe.output.amount },
|
||||
cook_time: recipe.cook_time,
|
||||
} satisfies RecipeJson,
|
||||
);
|
||||
}
|
||||
for (const [item, burn_time] of Object.entries(FUEL_VALUES)) {
|
||||
add("recipes", `fuel_${name_of(item)}`, "recipe", { type: "fuel", item, burn_time } satisfies RecipeJson);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
for (const folder of DATA_FOLDERS) {
|
||||
try {
|
||||
Deno.removeSync(`${MOD_DIR}/${folder}`, { recursive: true });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Deno.errors.NotFound)) throw e;
|
||||
}
|
||||
Deno.mkdirSync(`${MOD_DIR}/${folder}`, { recursive: true });
|
||||
}
|
||||
|
||||
const files = bworld_data_files();
|
||||
for (const { path, content } of files) {
|
||||
Deno.writeTextFileSync(`${MOD_DIR}/${path}`, JSON.stringify(content, null, "\t") + "\n");
|
||||
}
|
||||
|
||||
// match the repo's formatting so reruns don't show up as changes
|
||||
await new Deno.Command(Deno.execPath(), { args: ["fmt", "--quiet", ...DATA_FOLDERS.map((f) => `${MOD_DIR}/${f}`)] })
|
||||
.output();
|
||||
|
||||
const count = (folder: string) => files.filter((f) => f.path.startsWith(`${folder}/`)).length;
|
||||
console.log(
|
||||
`Wrote ${count("blocks")} blocks, ${count("items")} items and ${count("recipes")} recipes to ${MOD_DIR}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user