1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-12-09 16:21:51 +00:00

WIP: Basic ASAR modding in renderer

Also fix a few issues in Electron code. This is not as polished yet, UI
from old mod support was reused for now and is likely broken. Mods can
be loaded, but there isn't much QoL around the support for now.
This commit is contained in:
Даниїл Григор'єв 2025-04-12 20:06:03 +03:00
parent 321f517593
commit ad8e39bdf4
No known key found for this signature in database
GPG Key ID: B890DF16341D8C1D
17 changed files with 446 additions and 326 deletions

View File

@ -8,8 +8,14 @@
"name": "electron",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"semver": "^7.7.1",
"zod": "^3.24.2"
},
"devDependencies": {
"electron": "^31.3.0"
"@types/semver": "^7.7.0",
"electron": "^31.3.0",
"typescript": "^5.8.2"
}
},
"node_modules/@electron/get": {
@ -34,6 +40,16 @@
"global-agent": "^3.0.0"
}
},
"node_modules/@electron/get/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -110,6 +126,13 @@
"@types/node": "*"
}
},
"node_modules/@types/semver": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz",
"integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -449,20 +472,6 @@
"node": ">=10.0"
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@ -764,13 +773,15 @@
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver-compare": {
@ -833,6 +844,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@ -867,6 +892,15 @@
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -8,8 +8,12 @@
"scripts": {
"start": "tsc && electron ."
},
"dependencies": {},
"dependencies": {
"semver": "^7.7.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/semver": "^7.7.0",
"electron": "^31.3.0",
"typescript": "^5.8.2"
}

View File

@ -34,7 +34,7 @@ function createWindow() {
// The protocol can only be handled after "ready" event
modProtocol.install();
win = new BrowserWindow({
const window = new BrowserWindow({
minWidth: 800,
minHeight: 600,
useContentSize: true,
@ -46,24 +46,26 @@ function createWindow() {
},
});
win = window;
if (!switches.dev) {
win.removeMenu();
window.removeMenu();
}
win.on("ready-to-show", () => {
win.show();
window.on("ready-to-show", () => {
window.show();
if (switches.dev && !switches.hideDevtools) {
win.webContents.openDevTools();
window.webContents.openDevTools();
}
});
ipc.install(win);
win.loadURL(pageUrl);
ipc.install(window);
window.loadURL(pageUrl);
// Redirect any kind of main frame navigation to external applications
win.webContents.on("will-navigate", (ev, url) => {
if (url === win.webContents.getURL()) {
window.webContents.on("will-navigate", (ev, url) => {
if (url === window.webContents.getURL()) {
// Avoid handling reloads externally
return;
}
@ -73,7 +75,7 @@ function createWindow() {
});
// Also redirect window.open
win.webContents.setWindowOpenHandler(({ url }) => {
window.webContents.setWindowOpenHandler(({ url }) => {
openExternalUrl(url);
return { action: "deny" };
});

View File

@ -1,13 +1,10 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DevelopmentModLocator, DistroModLocator, ModLocator, UserModLocator } from "./locator.js";
import { IpcModMetadata, ModMetadata } from "./metadata.js";
type ModSource = "user" | "distro" | "dev";
// FIXME: temporary type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ModMetadata = any;
interface ModLocation {
source: ModSource;
file: string;
@ -18,13 +15,39 @@ interface DisabledMod {
id: string;
}
interface Mod extends ModLocation {
interface IpcMod extends ModLocation {
disabled: boolean;
metadata: ModMetadata;
metadata: IpcModMetadata;
}
const METADATA_FILE = "mod.json";
class Mod {
readonly source: ModSource;
readonly file: string;
readonly metadata: ModMetadata;
disabled = false;
constructor(source: ModSource, file: string, metadata: ModMetadata) {
this.source = source;
this.file = file;
this.metadata = metadata;
}
toJSON(): IpcMod {
return {
source: this.source,
file: this.file,
disabled: this.disabled,
metadata: {
...this.metadata,
version: this.metadata.version.format(),
},
};
}
}
export class ModLoader {
private mods: Mod[] = [];
private readonly locators = new Map<ModSource, ModLocator>();
@ -37,6 +60,7 @@ export class ModLoader {
async loadMods(): Promise<void> {
const mods: Mod[] = [];
this.mods = mods;
const locations = await this.locateAllMods();
for (const location of locations) {
@ -51,11 +75,7 @@ export class ModLoader {
continue;
}
mods.push({
...location,
disabled: false,
metadata,
});
mods.push(new Mod(location.source, location.file, metadata));
}
// Check for mods that should be disabled
@ -65,14 +85,10 @@ export class ModLoader {
target.disabled = true;
}
}
this.mods = mods;
}
getAllMods(): Mod[] {
// This is the IPC response handler for now
// FIXME: review the format of get-mods IPC message
return [...this.mods];
getAllMods(): IpcMod[] {
return this.mods.map(mod => mod.toJSON());
}
isModPresent(id: string): boolean {
@ -102,7 +118,7 @@ export class ModLoader {
const filePath = path.join(mod.file, METADATA_FILE);
try {
const contents = await fs.readFile(filePath, "utf-8");
return JSON.parse(contents);
return ModMetadata.parse(JSON.parse(contents));
} catch (err) {
// TODO: Collect mod errors, show to the user once all mods are loaded
console.error("Failed to read mod metadata", err);

View File

@ -57,7 +57,7 @@ abstract class DirectoryModLocator implements ModLocator {
.filter(entry => entry.name.endsWith(MOD_FILE_SUFFIX))
.map(entry => path.join(entry.path, entry.name));
} catch (err) {
if ("code" in err && err.code === "ENOENT") {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// The directory does not exist
return [];
}
@ -86,7 +86,7 @@ abstract class DirectoryModLocator implements ModLocator {
await this.readDisabledModsFile();
}
return [...this.disabledMods];
return [...this.disabledMods!];
}
private async readDisabledModsFile(): Promise<void> {
@ -100,7 +100,7 @@ abstract class DirectoryModLocator implements ModLocator {
// Ensure we don't fail twice
this.disabledMods ??= new Set();
if ("code" in err && err.code == "ENOENT") {
if ((err as NodeJS.ErrnoException).code == "ENOENT") {
// Ignore error entirely if the file is missing
return;
}
@ -116,7 +116,7 @@ abstract class DirectoryModLocator implements ModLocator {
private async writeDisabledModsFile(): Promise<void> {
try {
const contents = JSON.stringify([...this.disabledMods]);
const contents = JSON.stringify([...(this.disabledMods ?? new Set())]);
await fs.writeFile(this.disabledModsFile, contents, "utf-8");
} catch (err: unknown) {
// Nothing we can do

View File

@ -0,0 +1,38 @@
import SemVer from "semver/classes/semver.js";
import { z } from "zod";
const semver = z.string().transform((str, ctx) => {
try {
return new SemVer(str);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Not a valid SemVer version string",
});
return z.NEVER;
}
});
// TBD: dependencies, icons, readme
export const ModMetadata = z.object({
format: z.literal(1),
id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,48}[a-z0-9]$/g),
entry: z.string().nonempty(),
name: z.string().nonempty(),
description: z.ostring(),
authors: z
.object({
name: z.string().nonempty(),
website: z.string().url().optional(),
})
.array(),
version: semver,
savegameResident: z.boolean().default(true),
website: z.string().url().optional(),
source: z.string().url().optional(),
});
export type ModMetadata = z.infer<typeof ModMetadata>;
export type IpcModMetadata = Omit<ModMetadata, "version"> & {
version: string;
};

View File

@ -34,18 +34,13 @@ export class ModProtocolHandler {
try {
const fileUrl = this.getFileUrlForRequest(request);
if (fileUrl === undefined) {
return new Response(undefined, {
status: 404,
statusText: "Not Found",
});
return Response.error();
}
return net.fetch(fileUrl);
} catch {
return new Response(undefined, {
status: 400,
statusText: "Bad Request",
});
return await net.fetch(fileUrl);
} catch (err) {
console.error("Failed to fetch:", err);
return Response.error();
}
}

View File

@ -3,6 +3,7 @@
"include": ["./src/**/*"],
"compilerOptions": {
"noEmit": false,
"outDir": "./dist"
"outDir": "./dist",
"strict": true
}
}

View File

@ -0,0 +1,7 @@
import { Mod } from "./mod";
export class DisabledMod extends Mod {
init(): void | Promise<void> {
// Do nothing
}
}

View File

@ -0,0 +1,11 @@
import { Mod } from "./mod";
/**
* This {@link Mod} subclass is used to differentiate disabled mods and those
* that couldn't be parsed or constructed due to an error.
*/
export class ErroredMod extends Mod {
init(): void | Promise<void> {
// Do nothing
}
}

View File

@ -1,36 +0,0 @@
/* typehints:start */
import { Application } from "../application";
import { ModLoader } from "./modloader";
/* typehints:end */
import { MOD_SIGNALS } from "./mod_signals";
export class Mod {
/**
* @param {object} param0
* @param {Application} param0.app
* @param {ModLoader} param0.modLoader
* @param {import("./modloader").ModMetadata} param0.meta
* @param {Object} param0.settings
* @param {() => Promise<void>} param0.saveSettings
*/
constructor({ app, modLoader, meta, settings, saveSettings }) {
this.app = app;
this.modLoader = modLoader;
this.metadata = meta;
this.signals = MOD_SIGNALS;
this.modInterface = modLoader.modInterface;
this.settings = settings;
this.saveSettings = saveSettings;
}
init() {
// to be overridden
}
get dialogs() {
return this.modInterface.dialogs;
}
}

46
src/js/mods/mod.ts Normal file
View File

@ -0,0 +1,46 @@
import { Application } from "@/application";
import { ModInterface } from "./mod_interface";
import { FrozenModMetadata, ModMetadata } from "./mod_metadata";
import { MOD_SIGNALS } from "./mod_signals";
import { ModLoader } from "./modloader";
export type ModConstructor = new (metadata: ModMetadata, app: Application, modLoader: ModLoader) => Mod;
function freezeMetadata(metadata: ModMetadata): FrozenModMetadata {
// Note: Object.freeze doesn't create a copy of the object
for (const author of metadata.authors) {
Object.freeze(author);
}
Object.freeze(metadata.authors);
return Object.freeze(metadata);
}
export abstract class Mod {
// TODO: Review what properties are necessary while improving ModInterface
protected readonly app: Application;
protected readonly modLoader: ModLoader;
protected readonly modInterface: ModInterface;
protected readonly signals = MOD_SIGNALS;
// Exposed for convenience
readonly id: string;
readonly metadata: FrozenModMetadata;
readonly errors: Error[] = [];
constructor(metadata: ModMetadata, app: Application, modLoader: ModLoader) {
this.app = app;
this.modLoader = modLoader;
// TODO: ModInterface should accept the mod instance
this.modInterface = new ModInterface(modLoader);
this.id = metadata.id;
this.metadata = freezeMetadata(metadata);
}
abstract init(): void | Promise<void>;
get dialogs() {
return this.modInterface.dialogs;
}
}

38
src/js/mods/mod_metadata.d.ts vendored Normal file
View File

@ -0,0 +1,38 @@
import { Mod } from "./mod";
export interface ModAuthor {
name: string;
website?: string;
}
export interface ModMetadata {
// format: 1;
id: string;
entry: string;
name: string;
description?: string;
authors: ModAuthor[];
version: string;
savegameResident: boolean;
website?: string;
source?: string;
}
export type ModSource = "user" | "distro" | "dev";
export interface ModQueueEntry {
source: ModSource;
file: string;
disabled: boolean;
metadata: ModMetadata;
}
export interface ModInfo {
source: ModSource;
file: string;
mod: Mod;
}
export interface FrozenModMetadata extends Readonly<ModMetadata> {
authors: ReadonlyArray<Readonly<ModAuthor>>;
}

View File

@ -1,211 +0,0 @@
import { GLOBAL_APP } from "@/core/globals";
import { FsError } from "@/platform/fs_error";
import { createLogger } from "../core/logging";
import { Storage } from "../platform/storage";
import { Mod } from "./mod";
import { ModInterface } from "./mod_interface";
import { MOD_SIGNALS } from "./mod_signals";
const LOG = createLogger("mods");
/**
* @typedef {{
* name: string;
* version: string;
* author: string;
* website: string;
* description: string;
* id: string;
* settings: [];
* doesNotAffectSavegame?: boolean
* }} ModMetadata
*/
export class ModLoader {
constructor() {
LOG.log("modloader created");
/** @type {Mod[]} */
this.mods = [];
this.modInterface = new ModInterface(this);
/** @type {({ meta: ModMetadata, modClass: typeof Mod})[]} */
this.modLoadQueue = [];
this.initialized = false;
this.signals = MOD_SIGNALS;
}
get app() {
return GLOBAL_APP;
}
anyModsActive() {
return this.mods.length > 0;
}
/**
*
* @returns {import("../savegame/savegame_typedefs").SavegameStoredMods}
*/
getModsListForSavegame() {
return this.mods
.filter(mod => !mod.metadata.doesNotAffectSavegame)
.map(mod => ({
id: mod.metadata.id,
version: mod.metadata.version,
website: mod.metadata.website,
name: mod.metadata.name,
author: mod.metadata.author,
}));
}
/**
*
* @param {import("../savegame/savegame_typedefs").SavegameStoredMods} originalMods
*/
computeModDifference(originalMods) {
/**
* @type {import("../savegame/savegame_typedefs").SavegameStoredMods}
*/
let missing = [];
const current = this.getModsListForSavegame();
originalMods.forEach(mod => {
for (let i = 0; i < current.length; ++i) {
const currentMod = current[i];
if (currentMod.id === mod.id && currentMod.version === mod.version) {
current.splice(i, 1);
return;
}
}
missing.push(mod);
});
return {
missing,
extra: current,
};
}
exposeExports() {
const exports = {};
const modules = import.meta.webpackContext("../", {
recursive: true,
regExp: /\.[jt]sx?$/,
exclude: /\.d\.ts$/,
});
Array.from(modules.keys()).forEach(key => {
/** @type {object} */
const module = modules(key);
for (const member in module) {
if (member === "default") {
continue;
}
if (exports[member]) {
throw new Error("Duplicate export of " + member);
}
Object.defineProperty(exports, member, {
get() {
return module[member];
},
});
}
});
window.shapez = exports;
}
async initMods() {
// Create a storage for reading mod settings
const storage = new Storage(this.app);
await storage.initialize();
LOG.log("hook:init", this.app, this.app.storage);
this.exposeExports();
// TODO: Make use of the passed file name, or wait for ModV2
let mods = await ipcRenderer.invoke("get-mods");
mods = mods.map(mod => mod.source);
window.$shapez_registerMod = (modClass, meta) => {
if (this.initialized) {
throw new Error("Can't register mod after modloader is initialized");
}
if (this.modLoadQueue.some(entry => entry.meta.id === meta.id)) {
console.warn("Not registering mod", meta, "since a mod with the same id is already loaded");
return;
}
this.modLoadQueue.push({
modClass,
meta,
});
};
mods.forEach(modCode => {
modCode += `
if (typeof Mod !== 'undefined') {
if (typeof METADATA !== 'object') {
throw new Error("No METADATA variable found");
}
window.$shapez_registerMod(Mod, METADATA);
}
`;
try {
const func = new Function(modCode);
func();
} catch (ex) {
console.error(ex);
alert("Failed to parse mod (launch with --dev for more info): \n\n" + ex);
}
});
delete window.$shapez_registerMod;
for (let i = 0; i < this.modLoadQueue.length; i++) {
const { modClass, meta } = this.modLoadQueue[i];
const modDataFile = "modsettings_" + meta.id + "__" + meta.version + ".json";
let settings = meta.settings;
if (meta.settings) {
try {
const storedSettings = await storage.readFileAsync(modDataFile);
settings = JSON.parse(storedSettings);
} catch (ex) {
if (ex instanceof FsError && ex.isFileNotFound()) {
// Write default data
await storage.writeFileAsync(modDataFile, JSON.stringify(meta.settings));
} else {
alert("Failed to load settings for " + meta.id + ", will use defaults:\n\n" + ex);
}
}
}
try {
const mod = new modClass({
app: this.app,
modLoader: this,
meta,
settings,
saveSettings: () => storage.writeFileAsync(modDataFile, JSON.stringify(mod.settings)),
});
await mod.init();
this.mods.push(mod);
} catch (ex) {
console.error(ex);
alert("Failed to initialize mods (launch with --dev for more info): \n\n" + ex);
}
}
this.modLoadQueue = [];
this.initialized = true;
}
}
export const MODS = new ModLoader();

172
src/js/mods/modloader.ts Normal file
View File

@ -0,0 +1,172 @@
import { GLOBAL_APP } from "@/core/globals";
import { SavegameStoredMods } from "@/savegame/savegame_typedefs";
import { createLogger } from "../core/logging";
import { DisabledMod } from "./disabled_mod";
import { ErroredMod } from "./errored_mod";
import { Mod, ModConstructor } from "./mod";
import { ModInfo, ModMetadata, ModQueueEntry } from "./mod_metadata";
import { MOD_SIGNALS } from "./mod_signals";
const LOG = createLogger("mods");
export class ModLoader {
private readonly mods = new Map<string, ModInfo>();
// FIXME: Used for ModInterface, should be improved?
readonly signals = MOD_SIGNALS;
constructor() {
LOG.log("modloader created");
}
get app() {
return GLOBAL_APP;
}
get allMods(): ModInfo[] {
return [...this.mods.values()];
}
get activeMods(): ModInfo[] {
const mods: ModInfo[] = [];
for (const mod of this.mods.values()) {
if (mod.mod instanceof DisabledMod) {
continue;
}
mods.push(mod);
}
return mods;
}
getModsListForSavegame(): SavegameStoredMods {
// FIXME: new implementation TBD
return this.activeMods
.filter(info => info.mod.metadata.savegameResident)
.map(({ mod }) => ({
id: mod.metadata.id,
version: mod.metadata.version,
website: mod.metadata.website,
name: mod.metadata.name,
author: mod.metadata.authors.map(a => a.name).join(","),
}));
}
computeModDifference(originalMods: SavegameStoredMods) {
// FIXME: new implementation TBD
const missing: SavegameStoredMods = [];
const current = this.getModsListForSavegame();
originalMods.forEach(mod => {
for (let i = 0; i < current.length; ++i) {
const currentMod = current[i];
if (currentMod.id === mod.id && currentMod.version === mod.version) {
current.splice(i, 1);
return;
}
}
missing.push(mod);
});
return {
missing,
extra: current,
};
}
async initMods() {
this.exposeExports();
const queue: ModQueueEntry[] = await ipcRenderer.invoke("get-mods");
// Mods can be parsed and constructed in parallel
const loadedMods = await Promise.all(
queue.map(async e => ({ entry: e, mod: await this.loadMod(e) }))
);
// Initialize all mods sequentially and collect errors
// TODO: Also collect early errors from the main process
for (const { entry, mod } of loadedMods) {
try {
await mod.init();
} catch (err) {
if (err instanceof Error) {
mod.errors.push(err);
}
}
this.mods.set(mod.id, {
source: entry.source,
file: entry.file,
mod,
});
}
}
private exposeExports() {
const exports = {};
const modules = import.meta.webpackContext("../", {
recursive: true,
regExp: /\.[jt]sx?$/,
exclude: /\.d\.ts$/,
});
Array.from(modules.keys()).forEach(key => {
const module: object = modules(key);
for (const member in module) {
if (member === "default") {
continue;
}
if (exports[member]) {
throw new Error("Duplicate export of " + member);
}
Object.defineProperty(exports, member, {
get() {
return module[member];
},
});
}
});
window.shapez = exports;
}
private async loadMod(entry: ModQueueEntry): Promise<Mod> {
if (entry.disabled) {
return new DisabledMod(entry.metadata, this.app, this);
}
try {
return await this.createModInstance(entry.metadata);
} catch (err) {
const mod = new ErroredMod(entry.metadata, this.app, this);
mod.errors.push(err instanceof Error ? err : new Error(err.toString()));
return mod;
}
}
private async createModInstance(metadata: ModMetadata): Promise<Mod> {
const url = this.getModEntryUrl(metadata);
const module = await import(/* webpackIgnore: true */ url);
if (!(module.default?.prototype instanceof Mod)) {
throw new Error("Default export is not a Mod constructor");
}
const modClass: ModConstructor = module.default;
const mod = new modClass(metadata, this.app, this);
if (mod.id !== metadata.id) {
throw new Error(`Mod was created with invalid ID "${mod.id}"`);
}
return mod;
}
private getModEntryUrl(mod: ModMetadata): string {
return `mod://${mod.id}/${mod.entry}`;
}
}
export const MODS = new ModLoader();

View File

@ -30,7 +30,7 @@ export class MainMenuState extends GameState {
}
getInnerHTML() {
const hasMods = MODS.anyModsActive();
const hasMods = MODS.allMods.length > 0;
return `
<div class="topButtons">
@ -77,16 +77,10 @@ export class MainMenuState extends GameState {
<button class="styledButton editMods"></button>
</div>
<div class="modsList">
${MODS.mods
.map(mod => {
return `
<div class="mod">
<div class="name">${mod.metadata.name}</div>
<div class="author">by ${mod.metadata.author}</div>
</div>
`;
})
.join("")}
<div class="mod">
<div class="name">Mod support in progress</div>
<div class="author">Not implemented yet</div>
</div>
</div>
<div class="dlcHint">

View File

@ -1,4 +1,5 @@
import { Mod } from "@/mods/mod";
import { ModAuthor } from "@/mods/mod_metadata";
import { MODS } from "@/mods/modloader";
import { TextualGameState } from "../core/textual_game_state";
import { T } from "../translations";
@ -13,13 +14,17 @@ export class ModsState extends TextualGameState {
}
protected getInitialContent() {
const modElements = MODS.mods.map(mod => this.getModElement(mod));
// TODO: implement proper UI for disabled, errored mods etc.
const modElements = MODS.allMods.map(info => this.getModElement(info.mod));
const hasMods = modElements.length > 0;
if (!hasMods) {
modElements.push(this.getNoModsMessage());
}
return (
<div class="content">
<div class={`modsGrid${MODS.anyModsActive() ? "" : " noMods"}`}>
{MODS.anyModsActive() ? modElements : this.getNoModsMessage()}
</div>
<div class={`modsGrid ${hasMods ? "" : "noMods"}`}>{modElements}</div>
</div>
);
}
@ -29,7 +34,7 @@ export class ModsState extends TextualGameState {
return (
<div class="mod">
<div class="title">
<b>{mod.metadata.name}</b> by <i>{mod.metadata.author}</i>
<b>{mod.metadata.name}</b> by <i>{this.formatAuthors(mod.metadata.authors)}</i>
</div>
<div class="description">{mod.metadata.description}</div>
<div class="advanced">
@ -39,6 +44,10 @@ export class ModsState extends TextualGameState {
);
}
private formatAuthors(authors: readonly ModAuthor[]): string {
return authors.map(author => author.name).join(", ");
}
private getNoModsMessage(): HTMLElement {
return <div class="noModsMessage">No mods are currently installed.</div>;
}