} extendsPrams
+ */
+
+export class ModInterface {
+ /**
+ *
+ * @param {ModLoader} modLoader
+ */
+ constructor(modLoader) {
+ this.modLoader = modLoader;
+ }
+
+ registerCss(cssString) {
+ // Preprocess css
+ cssString = cssString.replace(/\$scaled\(([^)]*)\)/gim, (substr, expression) => {
+ return "calc((" + expression + ") * var(--ui-scale))";
+ });
+ const element = document.createElement("style");
+ element.textContent = cssString;
+ document.head.appendChild(element);
+ }
+
+ registerSprite(spriteId, base64string) {
+ assert(base64string.startsWith("data:image"));
+ const img = new Image();
+
+ const sprite = new AtlasSprite(spriteId);
+ sprite.frozen = true;
+
+ img.addEventListener("load", () => {
+ for (const resolution in sprite.linksByResolution) {
+ const link = sprite.linksByResolution[resolution];
+ link.w = img.width;
+ link.h = img.height;
+ link.packedW = img.width;
+ link.packedH = img.height;
+ }
+ });
+
+ img.src = base64string;
+
+ const link = new SpriteAtlasLink({
+ w: 1,
+ h: 1,
+ atlas: img,
+ packOffsetX: 0,
+ packOffsetY: 0,
+ packedW: 1,
+ packedH: 1,
+ packedX: 0,
+ packedY: 0,
+ });
+
+ sprite.linksByResolution["0.25"] = link;
+ sprite.linksByResolution["0.5"] = link;
+ sprite.linksByResolution["0.75"] = link;
+
+ Loader.sprites.set(spriteId, sprite);
+ }
+
+ /**
+ *
+ * @param {string} imageBase64
+ * @param {string} jsonTextData
+ */
+ registerAtlas(imageBase64, jsonTextData) {
+ const atlasData = JSON.parse(jsonTextData);
+ const img = new Image();
+ img.src = imageBase64;
+
+ const sourceData = atlasData.frames;
+ for (const spriteName in sourceData) {
+ const { frame, sourceSize, spriteSourceSize } = sourceData[spriteName];
+
+ const sprite = new AtlasSprite(spriteName);
+ Loader.sprites.set(spriteName, sprite);
+ sprite.frozen = true;
+
+ const link = new SpriteAtlasLink({
+ packedX: frame.x,
+ packedY: frame.y,
+ packedW: frame.w,
+ packedH: frame.h,
+ packOffsetX: spriteSourceSize.x,
+ packOffsetY: spriteSourceSize.y,
+ atlas: img,
+ w: sourceSize.w,
+ h: sourceSize.h,
+ });
+ sprite.linksByResolution["0.25"] = link;
+ sprite.linksByResolution["0.5"] = link;
+ sprite.linksByResolution["0.75"] = link;
+ }
+ }
+
+ /**
+ *
+ * @param {object} param0
+ * @param {string} param0.id
+ * @param {string} param0.shortCode
+ * @param {(distanceToOriginInChunks: number) => number} param0.weightComputation
+ * @param {(options: import("../game/shape_definition").SubShapeDrawOptions) => void} param0.draw
+ */
+ registerSubShapeType({ id, shortCode, weightComputation, draw }) {
+ if (shortCode.length !== 1) {
+ throw new Error("Bad short code: " + shortCode);
+ }
+ enumSubShape[id] = id;
+ enumSubShapeToShortcode[id] = shortCode;
+ enumShortcodeToSubShape[shortCode] = id;
+
+ MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS[id] = weightComputation;
+ MODS_ADDITIONAL_SUB_SHAPE_DRAWERS[id] = draw;
+ }
+
+ registerTranslations(language, translations) {
+ const data = LANGUAGES[language];
+ if (!data) {
+ throw new Error("Unknown language: " + language);
+ }
+
+ matchDataRecursive(data.data, translations, true);
+ if (language === "en") {
+ matchDataRecursive(T, translations, true);
+ }
+ }
+
+ /**
+ *
+ * @param {typeof Component} component
+ */
+ registerComponent(component) {
+ gComponentRegistry.register(component);
+ }
+
+ /**
+ *
+ * @param {Object} param0
+ * @param {string} param0.id
+ * @param {new (any) => GameSystem} param0.systemClass
+ * @param {string=} param0.before
+ * @param {string[]=} param0.drawHooks
+ */
+ registerGameSystem({ id, systemClass, before, drawHooks }) {
+ const key = before || "key";
+ const payload = { id, systemClass };
+
+ if (MODS_ADDITIONAL_SYSTEMS[key]) {
+ MODS_ADDITIONAL_SYSTEMS[key].push(payload);
+ } else {
+ MODS_ADDITIONAL_SYSTEMS[key] = [payload];
+ }
+ if (drawHooks) {
+ drawHooks.forEach(hookId => this.registerGameSystemDrawHook(hookId, id));
+ }
+ }
+
+ /**
+ *
+ * @param {string} hookId
+ * @param {string} systemId
+ */
+ registerGameSystemDrawHook(hookId, systemId) {
+ if (!MOD_CHUNK_DRAW_HOOKS[hookId]) {
+ throw new Error("bad game system draw hook: " + hookId);
+ }
+ MOD_CHUNK_DRAW_HOOKS[hookId].push(systemId);
+ }
+
+ /**
+ *
+ * @param {object} param0
+ * @param {typeof ModMetaBuilding} param0.metaClass
+ * @param {string=} param0.buildingIconBase64
+ */
+ registerNewBuilding({ metaClass, buildingIconBase64 }) {
+ const id = new /** @type {new (...args) => ModMetaBuilding} */ (metaClass)().getId();
+ if (gMetaBuildingRegistry.hasId(id)) {
+ throw new Error("Tried to register building twice: " + id);
+ }
+ gMetaBuildingRegistry.register(metaClass);
+ const metaInstance = gMetaBuildingRegistry.findByClass(metaClass);
+ T.buildings[id] = {};
+
+ metaClass.getAllVariantCombinations().forEach(combination => {
+ const variant = combination.variant || defaultBuildingVariant;
+ const rotationVariant = combination.rotationVariant || 0;
+
+ const buildingIdentifier = id + (variant === defaultBuildingVariant ? "" : "-" + variant);
+
+ const uniqueTypeId = buildingIdentifier + (rotationVariant === 0 ? "" : "-" + rotationVariant);
+ registerBuildingVariant(uniqueTypeId, metaClass, variant, rotationVariant);
+
+ gBuildingVariants[id].metaInstance = metaInstance;
+
+ this.registerTranslations("en", {
+ buildings: {
+ [id]: {
+ [variant]: {
+ name: combination.name || "Name",
+ description: combination.description || "Description",
+ },
+ },
+ },
+ });
+
+ if (combination.regularImageBase64) {
+ this.registerSprite(
+ "sprites/buildings/" + buildingIdentifier + ".png",
+ combination.regularImageBase64
+ );
+ }
+
+ if (combination.blueprintImageBase64) {
+ this.registerSprite(
+ "sprites/blueprints/" + buildingIdentifier + ".png",
+ combination.blueprintImageBase64
+ );
+ }
+ if (combination.tutorialImageBase64) {
+ this.setBuildingTutorialImage(id, variant, combination.tutorialImageBase64);
+ }
+ });
+
+ if (buildingIconBase64) {
+ this.setBuildingToolbarIcon(id, buildingIconBase64);
+ }
+ }
+
+ /**
+ *
+ * @param {Object} param0
+ * @param {string} param0.id
+ * @param {number} param0.keyCode
+ * @param {string} param0.translation
+ * @param {boolean=} param0.repeated
+ * @param {((GameRoot) => void)=} param0.handler
+ * @param {{shift?: boolean; alt?: boolean; ctrl?: boolean}=} param0.modifiers
+ * @param {boolean=} param0.builtin
+ */
+ registerIngameKeybinding({
+ id,
+ keyCode,
+ translation,
+ modifiers = {},
+ repeated = false,
+ builtin = false,
+ handler = null,
+ }) {
+ if (!KEYMAPPINGS.mods) {
+ KEYMAPPINGS.mods = {};
+ }
+ const binding = (KEYMAPPINGS.mods[id] = {
+ keyCode,
+ id,
+ repeated,
+ modifiers,
+ builtin,
+ });
+ this.registerTranslations("en", {
+ keybindings: {
+ mappings: {
+ [id]: translation,
+ },
+ },
+ });
+
+ if (handler) {
+ this.modLoader.signals.gameStarted.add(root => {
+ root.keyMapper.getBindingById(id).addToTop(handler.bind(null, root));
+ });
+ }
+
+ return binding;
+ }
+
+ /**
+ * @returns {HUDModalDialogs}
+ */
+ get dialogs() {
+ const state = this.modLoader.app.stateMgr.currentState;
+ // @ts-ignore
+ if (state.dialogs) {
+ // @ts-ignore
+ return state.dialogs;
+ }
+ throw new Error("Tried to access dialogs but current state doesn't support it");
+ }
+
+ setBuildingToolbarIcon(buildingId, iconBase64) {
+ this.registerCss(`
+ [data-icon="building_icons/${buildingId}.png"] .icon {
+ background-image: url('${iconBase64}') !important;
+ }
+ `);
+ }
+
+ /**
+ *
+ * @param {string | (new () => MetaBuilding)} buildingIdOrClass
+ * @param {*} variant
+ * @param {*} imageBase64
+ */
+ setBuildingTutorialImage(buildingIdOrClass, variant, imageBase64) {
+ if (typeof buildingIdOrClass === "function") {
+ buildingIdOrClass = new buildingIdOrClass().id;
+ }
+ const buildingIdentifier =
+ buildingIdOrClass + (variant === defaultBuildingVariant ? "" : "-" + variant);
+
+ this.registerCss(`
+ [data-icon="building_tutorials/${buildingIdentifier}.png"] {
+ background-image: url('${imageBase64}') !important;
+ }
+ `);
+ }
+
+ /**
+ * @param {Object} param0
+ * @param {string} param0.id
+ * @param {string} param0.name
+ * @param {Object} param0.theme
+ */
+ registerGameTheme({ id, name, theme }) {
+ THEMES[id] = theme;
+ this.registerTranslations("en", {
+ settings: {
+ labels: {
+ theme: {
+ themes: {
+ [id]: name,
+ },
+ },
+ },
+ },
+ });
+ }
+
+ /**
+ * Registers a new state class, should be a GameState derived class
+ * @param {typeof import("../core/game_state").GameState} stateClass
+ */
+ registerGameState(stateClass) {
+ this.modLoader.app.stateMgr.register(stateClass);
+ }
+
+ /**
+ * @param {object} param0
+ * @param {"regular"|"wires"} param0.toolbar
+ * @param {"primary"|"secondary"} param0.location
+ * @param {typeof MetaBuilding} param0.metaClass
+ */
+ addNewBuildingToToolbar({ toolbar, location, metaClass }) {
+ const hudElementName = toolbar === "wires" ? "HUDWiresToolbar" : "HUDBuildingsToolbar";
+ const property = location === "secondary" ? "secondaryBuildings" : "primaryBuildings";
+
+ this.modLoader.signals.hudElementInitialized.add(element => {
+ if (element.constructor.name === hudElementName) {
+ element[property].push(metaClass);
+ }
+ });
+ }
+
+ /**
+ * Patches a method on a given class
+ * @template {constructable} C the class
+ * @template {C["prototype"]} P the prototype of said class
+ * @template {keyof P} M the name of the method we are overriding
+ * @template {extendsPrams} O the method that will override the old one
+ * @param {C} classHandle
+ * @param {M} methodName
+ * @param {bindThis, InstanceType>} override
+ */
+ replaceMethod(classHandle, methodName, override) {
+ const oldMethod = classHandle.prototype[methodName];
+ classHandle.prototype[methodName] = function () {
+ //@ts-ignore This is true I just cant tell it that arguments will be Arguments
+ return override.call(this, oldMethod.bind(this), arguments);
+ };
+ }
+
+ /**
+ * Runs before a method on a given class
+ * @template {constructable} C the class
+ * @template {C["prototype"]} P the prototype of said class
+ * @template {keyof P} M the name of the method we are overriding
+ * @template {extendsPrams} O the method that will run before the old one
+ * @param {C} classHandle
+ * @param {M} methodName
+ * @param {bindThis>} executeBefore
+ */
+ runBeforeMethod(classHandle, methodName, executeBefore) {
+ const oldHandle = classHandle.prototype[methodName];
+ classHandle.prototype[methodName] = function () {
+ //@ts-ignore Same as above
+ executeBefore.apply(this, arguments);
+ return oldHandle.apply(this, arguments);
+ };
+ }
+
+ /**
+ * Runs after a method on a given class
+ * @template {constructable} C the class
+ * @template {C["prototype"]} P the prototype of said class
+ * @template {keyof P} M the name of the method we are overriding
+ * @template {extendsPrams} O the method that will run before the old one
+ * @param {C} classHandle
+ * @param {M} methodName
+ * @param {bindThis>} executeAfter
+ */
+ runAfterMethod(classHandle, methodName, executeAfter) {
+ const oldHandle = classHandle.prototype[methodName];
+ classHandle.prototype[methodName] = function () {
+ const returnValue = oldHandle.apply(this, arguments);
+ //@ts-ignore
+ executeAfter.apply(this, arguments);
+ return returnValue;
+ };
+ }
+
+ /**
+ *
+ * @param {Object} prototype
+ * @param {({ $super, $old }) => any} extender
+ */
+ extendObject(prototype, extender) {
+ const $super = Object.getPrototypeOf(prototype);
+ const $old = {};
+ const extensionMethods = extender({ $super, $old });
+ const properties = Array.from(Object.getOwnPropertyNames(extensionMethods));
+ properties.forEach(propertyName => {
+ if (["constructor", "prototype"].includes(propertyName)) {
+ return;
+ }
+ $old[propertyName] = prototype[propertyName];
+ prototype[propertyName] = extensionMethods[propertyName];
+ });
+ }
+
+ /**
+ *
+ * @param {Class} classHandle
+ * @param {({ $super, $old }) => any} extender
+ */
+ extendClass(classHandle, extender) {
+ this.extendObject(classHandle.prototype, extender);
+ }
+
+ /**
+ *
+ * @param {string} id
+ * @param {new (...args) => BaseHUDPart} element
+ */
+ registerHudElement(id, element) {
+ this.modLoader.signals.hudInitializer.add(root => {
+ root.hud.parts[id] = new element(root);
+ });
+ }
+
+ /**
+ *
+ * @param {string | (new () => MetaBuilding)} buildingIdOrClass
+ * @param {string} variant
+ * @param {object} param0
+ * @param {string} param0.name
+ * @param {string} param0.description
+ * @param {string=} param0.language
+ */
+ registerBuildingTranslation(buildingIdOrClass, variant, { name, description, language = "en" }) {
+ if (typeof buildingIdOrClass === "function") {
+ buildingIdOrClass = new buildingIdOrClass().id;
+ }
+ this.registerTranslations(language, {
+ buildings: {
+ [buildingIdOrClass]: {
+ [variant]: {
+ name,
+ description,
+ },
+ },
+ },
+ });
+ }
+
+ /**
+ *
+ * @param {string | (new () => MetaBuilding)} buildingIdOrClass
+ * @param {string} variant
+ * @param {object} param2
+ * @param {string=} param2.regularBase64
+ * @param {string=} param2.blueprintBase64
+ */
+ registerBuildingSprites(buildingIdOrClass, variant, { regularBase64, blueprintBase64 }) {
+ if (typeof buildingIdOrClass === "function") {
+ buildingIdOrClass = new buildingIdOrClass().id;
+ }
+
+ const spriteId =
+ buildingIdOrClass + (variant === defaultBuildingVariant ? "" : "-" + variant) + ".png";
+
+ if (regularBase64) {
+ this.registerSprite("sprites/buildings/" + spriteId, regularBase64);
+ }
+
+ if (blueprintBase64) {
+ this.registerSprite("sprites/blueprints/" + spriteId, blueprintBase64);
+ }
+ }
+
+ /**
+ * @param {new () => MetaBuilding} metaClass
+ * @param {string} variant
+ * @param {object} payload
+ * @param {number[]=} payload.rotationVariants
+ * @param {string=} payload.tutorialImageBase64
+ * @param {string=} payload.regularSpriteBase64
+ * @param {string=} payload.blueprintSpriteBase64
+ * @param {string=} payload.name
+ * @param {string=} payload.description
+ * @param {Vector=} payload.dimensions
+ * @param {(root: GameRoot) => [string, string][]} payload.additionalStatistics
+ * @param {(root: GameRoot) => boolean[]} payload.isUnlocked
+ */
+ addVariantToExistingBuilding(metaClass, variant, payload) {
+ if (!payload.rotationVariants) {
+ payload.rotationVariants = [0];
+ }
+
+ if (payload.tutorialImageBase64) {
+ this.setBuildingTutorialImage(metaClass, variant, payload.tutorialImageBase64);
+ }
+ if (payload.regularSpriteBase64) {
+ this.registerBuildingSprites(metaClass, variant, { regularBase64: payload.regularSpriteBase64 });
+ }
+ if (payload.blueprintSpriteBase64) {
+ this.registerBuildingSprites(metaClass, variant, {
+ blueprintBase64: payload.blueprintSpriteBase64,
+ });
+ }
+ if (payload.name && payload.description) {
+ this.registerBuildingTranslation(metaClass, variant, {
+ name: payload.name,
+ description: payload.description,
+ });
+ }
+
+ const internalId = new metaClass().getId() + "-" + variant;
+
+ // Extend static methods
+ this.extendObject(metaClass, ({ $old }) => ({
+ getAllVariantCombinations() {
+ return [
+ ...$old.bind(this).getAllVariantCombinations(),
+ ...payload.rotationVariants.map(rotationVariant => ({
+ internalId,
+ variant,
+ rotationVariant,
+ })),
+ ];
+ },
+ }));
+
+ // Dimensions
+ const $variant = variant;
+ if (payload.dimensions) {
+ this.extendClass(metaClass, ({ $old }) => ({
+ getDimensions(variant) {
+ if (variant === $variant) {
+ return payload.dimensions;
+ }
+ return $old.getDimensions.bind(this)(...arguments);
+ },
+ }));
+ }
+
+ if (payload.additionalStatistics) {
+ this.extendClass(metaClass, ({ $old }) => ({
+ getAdditionalStatistics(root, variant) {
+ if (variant === $variant) {
+ return payload.additionalStatistics(root);
+ }
+ return $old.getAdditionalStatistics.bind(this)(root, variant);
+ },
+ }));
+ }
+
+ if (payload.isUnlocked) {
+ this.extendClass(metaClass, ({ $old }) => ({
+ getAvailableVariants(root) {
+ if (payload.isUnlocked(root)) {
+ return [...$old.getAvailableVariants.bind(this)(root), $variant];
+ }
+ return $old.getAvailableVariants.bind(this)(root);
+ },
+ }));
+ }
+
+ // Register our variant finally
+ payload.rotationVariants.forEach(rotationVariant =>
+ shapez.registerBuildingVariant(internalId, metaClass, variant, rotationVariant)
+ );
+ }
+}
diff --git a/src/js/mods/mod_meta_building.js b/src/js/mods/mod_meta_building.js
new file mode 100644
index 00000000..0d8f215a
--- /dev/null
+++ b/src/js/mods/mod_meta_building.js
@@ -0,0 +1,18 @@
+import { MetaBuilding } from "../game/meta_building";
+
+export class ModMetaBuilding extends MetaBuilding {
+ /**
+ * @returns {({
+ * variant: string;
+ * rotationVariant?: number;
+ * name: string;
+ * description: string;
+ * blueprintImageBase64?: string;
+ * regularImageBase64?: string;
+ * tutorialImageBase64?: string;
+ * }[])}
+ */
+ static getAllVariantCombinations() {
+ throw new Error("Implement getAllVariantCombinations");
+ }
+}
diff --git a/src/js/mods/mod_signals.js b/src/js/mods/mod_signals.js
new file mode 100644
index 00000000..a534dd89
--- /dev/null
+++ b/src/js/mods/mod_signals.js
@@ -0,0 +1,33 @@
+/* typehints:start */
+import { BaseHUDPart } from "../game/hud/base_hud_part";
+import { GameRoot } from "../game/root";
+import { GameState } from "../core/game_state";
+import { InGameState } from "../states/ingame";
+/* typehints:end */
+
+import { Signal } from "../core/signal";
+
+// Single file to avoid circular deps
+
+export const MOD_SIGNALS = {
+ // Called when the application has booted and instances like the app settings etc are available
+ appBooted: new Signal(),
+
+ modifyLevelDefinitions: /** @type {TypedSignal<[Array[Object]]>} */ (new Signal()),
+ modifyUpgrades: /** @type {TypedSignal<[Object]>} */ (new Signal()),
+
+ hudElementInitialized: /** @type {TypedSignal<[BaseHUDPart]>} */ (new Signal()),
+ hudElementFinalized: /** @type {TypedSignal<[BaseHUDPart]>} */ (new Signal()),
+
+ hudInitializer: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()),
+
+ gameInitialized: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()),
+ gameLoadingStageEntered: /** @type {TypedSignal<[InGameState, string]>} */ (new Signal()),
+
+ gameStarted: /** @type {TypedSignal<[GameRoot]>} */ (new Signal()),
+
+ stateEntered: /** @type {TypedSignal<[GameState]>} */ (new Signal()),
+
+ gameSerialized: /** @type {TypedSignal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ (new Signal()),
+ gameDeserialized: /** @type {TypedSignal<[GameRoot, import("../savegame/savegame_typedefs").SerializedGame]>} */ (new Signal()),
+};
diff --git a/src/js/mods/modloader.js b/src/js/mods/modloader.js
new file mode 100644
index 00000000..356b7d6b
--- /dev/null
+++ b/src/js/mods/modloader.js
@@ -0,0 +1,270 @@
+/* typehints:start */
+import { Application } from "../application";
+/* typehints:end */
+import { globalConfig } from "../core/config";
+import { createLogger } from "../core/logging";
+import { StorageImplBrowserIndexedDB } from "../platform/browser/storage_indexed_db";
+import { StorageImplElectron } from "../platform/electron/storage";
+import { FILE_NOT_FOUND } from "../platform/storage";
+import { Mod } from "./mod";
+import { ModInterface } from "./mod_interface";
+import { MOD_SIGNALS } from "./mod_signals";
+
+import semverValidRange from "semver/ranges/valid";
+import semverSatisifies from "semver/functions/satisfies";
+
+const LOG = createLogger("mods");
+
+/**
+ * @typedef {{
+ * name: string;
+ * version: string;
+ * author: string;
+ * website: string;
+ * description: string;
+ * id: string;
+ * minimumGameVersion?: string;
+ * settings: [];
+ * doesNotAffectSavegame?: boolean
+ * }} ModMetadata
+ */
+
+export class ModLoader {
+ constructor() {
+ LOG.log("modloader created");
+
+ /**
+ * @type {Application}
+ */
+ this.app = undefined;
+
+ /** @type {Mod[]} */
+ this.mods = [];
+
+ this.modInterface = new ModInterface(this);
+
+ /** @type {({ meta: ModMetadata, modClass: typeof Mod})[]} */
+ this.modLoadQueue = [];
+
+ this.initialized = false;
+
+ this.signals = MOD_SIGNALS;
+ }
+
+ linkApp(app) {
+ this.app = 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() {
+ if (G_IS_DEV || G_IS_STANDALONE) {
+ let exports = {};
+ const modules = require.context("../", true, /\.js$/);
+ Array.from(modules.keys()).forEach(key => {
+ // @ts-ignore
+ const module = modules(key);
+ for (const member in module) {
+ if (member === "default" || member === "$s") {
+ // Setter
+ continue;
+ }
+ if (exports[member]) {
+ throw new Error("Duplicate export of " + member);
+ }
+
+ Object.defineProperty(exports, member, {
+ get() {
+ return module[member];
+ },
+ set(v) {
+ module["$s"](member, v);
+ },
+ });
+ }
+ });
+
+ window.shapez = exports;
+ }
+ }
+
+ async initMods() {
+ if (!G_IS_STANDALONE && !G_IS_DEV) {
+ this.initialized = true;
+ return;
+ }
+
+ // Create a storage for reading mod settings
+ const storage = G_IS_STANDALONE
+ ? new StorageImplElectron(this.app)
+ : new StorageImplBrowserIndexedDB(this.app);
+ await storage.initialize();
+
+ LOG.log("hook:init", this.app, this.app.storage);
+ this.exposeExports();
+
+ let mods = [];
+ if (G_IS_STANDALONE) {
+ mods = await ipcRenderer.invoke("get-mods");
+ }
+ if (G_IS_DEV && globalConfig.debug.externalModUrl) {
+ const modURLs = Array.isArray(globalConfig.debug.externalModUrl)
+ ? globalConfig.debug.externalModUrl
+ : [globalConfig.debug.externalModUrl];
+
+ for (let i = 0; i < modURLs.length; i++) {
+ const response = await fetch(modURLs[i], {
+ method: "GET",
+ });
+ if (response.status !== 200) {
+ throw new Error(
+ "Failed to load " + modURLs[i] + ": " + response.status + " " + response.statusText
+ );
+ }
+ mods.push(await response.text());
+ }
+ }
+
+ 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";
+
+ if (meta.minimumGameVersion) {
+ const minimumGameVersion = meta.minimumGameVersion;
+ if (!semverValidRange(minimumGameVersion)) {
+ alert("Mod " + meta.id + " has invalid minimumGameVersion: " + minimumGameVersion);
+ continue;
+ }
+ if (!semverSatisifies(G_BUILD_VERSION, minimumGameVersion)) {
+ alert(
+ "Mod '" +
+ meta.id +
+ "' is incompatible with this version of the game: \n\n" +
+ "Mod requires version " +
+ minimumGameVersion +
+ " but this game has version " +
+ G_BUILD_VERSION
+ );
+ continue;
+ }
+ }
+
+ let settings = meta.settings;
+
+ if (meta.settings) {
+ try {
+ const storedSettings = await storage.readFileAsync(modDataFile);
+ settings = JSON.parse(storedSettings);
+ } catch (ex) {
+ if (ex === FILE_NOT_FOUND) {
+ // 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)),
+ });
+ 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();
diff --git a/src/js/platform/api.js b/src/js/platform/api.js
index d518c98a..4e7a82f9 100644
--- a/src/js/platform/api.js
+++ b/src/js/platform/api.js
@@ -3,7 +3,6 @@ import { Application } from "../application";
/* typehints:end */
import { createLogger } from "../core/logging";
import { compressX64 } from "../core/lzstring";
-import { getIPCRenderer } from "../core/utils";
import { T } from "../translations";
const logger = createLogger("puzzle-api");
@@ -113,9 +112,7 @@ export class ClientAPI {
return Promise.resolve({ token });
}
- const renderer = getIPCRenderer();
-
- return renderer.invoke("steam:get-ticket").then(
+ return ipcRenderer.invoke("steam:get-ticket").then(
ticket => {
logger.log("Got auth ticket:", ticket);
return this._request("/v1/public/login", {
diff --git a/src/js/platform/electron/steam_achievement_provider.js b/src/js/platform/electron/steam_achievement_provider.js
index c0ef552c..638cdbc5 100644
--- a/src/js/platform/electron/steam_achievement_provider.js
+++ b/src/js/platform/electron/steam_achievement_provider.js
@@ -4,7 +4,6 @@ import { GameRoot } from "../../game/root";
/* typehints:end */
import { createLogger } from "../../core/logging";
-import { getIPCRenderer } from "../../core/utils";
import { ACHIEVEMENTS, AchievementCollection, AchievementProviderInterface } from "../achievement_provider";
const logger = createLogger("achievements/steam");
@@ -109,9 +108,7 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
return Promise.resolve();
}
- this.ipc = getIPCRenderer();
-
- return this.ipc.invoke("steam:is-initialized").then(initialized => {
+ return ipcRenderer.invoke("steam:is-initialized").then(initialized => {
this.initialized = initialized;
if (!this.initialized) {
@@ -136,7 +133,7 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
if (!this.initialized) {
promise = Promise.resolve();
} else {
- promise = this.ipc.invoke("steam:activate-achievement", ACHIEVEMENT_IDS[key]);
+ promise = ipcRenderer.invoke("steam:activate-achievement", ACHIEVEMENT_IDS[key]);
}
return promise
diff --git a/src/js/platform/electron/storage.js b/src/js/platform/electron/storage.js
index 41ed1746..65f0e507 100644
--- a/src/js/platform/electron/storage.js
+++ b/src/js/platform/electron/storage.js
@@ -1,30 +1,8 @@
-import { StorageInterface } from "../storage";
-import { getIPCRenderer } from "../../core/utils";
-import { createLogger } from "../../core/logging";
-
-const logger = createLogger("electron-storage");
+import { FILE_NOT_FOUND, StorageInterface } from "../storage";
export class StorageImplElectron extends StorageInterface {
constructor(app) {
super(app);
-
- /** @type {Object.} */
- this.jobs = {};
- this.jobId = 0;
-
- getIPCRenderer().on("fs-response", (event, arg) => {
- const id = arg.id;
- if (!this.jobs[id]) {
- logger.warn("Got unhandled FS response, job not known:", id);
- return;
- }
- const { resolve, reject } = this.jobs[id];
- if (arg.result.success) {
- resolve(arg.result.data);
- } else {
- reject(arg.result.error);
- }
- });
}
initialize() {
@@ -32,44 +10,32 @@ export class StorageImplElectron extends StorageInterface {
}
writeFileAsync(filename, contents) {
- return new Promise((resolve, reject) => {
- // ipcMain
- const jobId = ++this.jobId;
- this.jobs[jobId] = { resolve, reject };
-
- getIPCRenderer().send("fs-job", {
- type: "write",
- filename,
- contents,
- id: jobId,
- });
+ return ipcRenderer.invoke("fs-job", {
+ type: "write",
+ filename,
+ contents,
});
}
readFileAsync(filename) {
- return new Promise((resolve, reject) => {
- // ipcMain
- const jobId = ++this.jobId;
- this.jobs[jobId] = { resolve, reject };
-
- getIPCRenderer().send("fs-job", {
+ return ipcRenderer
+ .invoke("fs-job", {
type: "read",
filename,
- id: jobId,
+ })
+ .then(res => {
+ if (res && res.error === FILE_NOT_FOUND) {
+ throw FILE_NOT_FOUND;
+ }
+
+ return res;
});
- });
}
deleteFileAsync(filename) {
- return new Promise((resolve, reject) => {
- // ipcMain
- const jobId = ++this.jobId;
- this.jobs[jobId] = { resolve, reject };
- getIPCRenderer().send("fs-job", {
- type: "delete",
- filename,
- id: jobId,
- });
+ return ipcRenderer.invoke("fs-job", {
+ type: "delete",
+ filename,
});
}
}
diff --git a/src/js/platform/electron/wrapper.js b/src/js/platform/electron/wrapper.js
index c1764f68..65451395 100644
--- a/src/js/platform/electron/wrapper.js
+++ b/src/js/platform/electron/wrapper.js
@@ -1,6 +1,5 @@
import { NoAchievementProvider } from "../browser/no_achievement_provider";
import { PlatformWrapperImplBrowser } from "../browser/wrapper";
-import { getIPCRenderer } from "../../core/utils";
import { createLogger } from "../../core/logging";
import { StorageImplElectron } from "./storage";
import { SteamAchievementProvider } from "./steam_achievement_provider";
@@ -71,15 +70,13 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
}
initializeDlcStatus() {
- const renderer = getIPCRenderer();
-
if (G_WEGAME_VERSION) {
return Promise.resolve();
}
logger.log("Checking DLC ownership ...");
// @todo: Don't hardcode the app id
- return renderer.invoke("steam:check-app-ownership", 1625400).then(
+ return ipcRenderer.invoke("steam:check-app-ownership", 1625400).then(
res => {
logger.log("Got DLC ownership:", res);
this.dlcs.puzzle = Boolean(res);
@@ -106,7 +103,7 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
}
setFullscreen(flag) {
- getIPCRenderer().send("set-fullscreen", flag);
+ ipcRenderer.send("set-fullscreen", flag);
}
getSupportsAppExit() {
@@ -115,6 +112,6 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
exitApp() {
logger.log(this, "Sending app exit signal");
- getIPCRenderer().send("exit-app");
+ ipcRenderer.send("exit-app");
}
}
diff --git a/src/js/profile/application_settings.js b/src/js/profile/application_settings.js
index 22074eae..03f4fdcf 100644
--- a/src/js/profile/application_settings.js
+++ b/src/js/profile/application_settings.js
@@ -122,7 +122,7 @@ export const autosaveIntervals = [
},
];
-const refreshRateOptions = ["30", "60", "120", "180", "240"];
+export const refreshRateOptions = ["30", "60", "120", "180", "240"];
if (G_IS_DEV) {
refreshRateOptions.unshift("10");
@@ -133,163 +133,161 @@ if (G_IS_DEV) {
refreshRateOptions.push("10000");
}
-/** @type {Array} */
-export const allApplicationSettings = [
- new EnumSetting("language", {
- options: Object.keys(LANGUAGES),
- valueGetter: key => key,
- textGetter: key => LANGUAGES[key].name,
- category: enumCategories.general,
- restartRequired: true,
- changeCb: (app, id) => null,
- magicValue: "auto-detect",
- }),
-
- new EnumSetting("uiScale", {
- options: uiScales.sort((a, b) => a.size - b.size),
- valueGetter: scale => scale.id,
- textGetter: scale => T.settings.labels.uiScale.scales[scale.id],
- category: enumCategories.userInterface,
- restartRequired: false,
- changeCb:
+/** @returns {Array} */
+function initializeSettings() {
+ return [
+ new EnumSetting("language", {
+ options: Object.keys(LANGUAGES),
+ valueGetter: key => key,
+ textGetter: key => LANGUAGES[key].name,
+ category: enumCategories.general,
+ restartRequired: true,
+ changeCb: (app, id) => null,
+ magicValue: "auto-detect",
+ }),
+
+ new EnumSetting("uiScale", {
+ options: uiScales.sort((a, b) => a.size - b.size),
+ valueGetter: scale => scale.id,
+ textGetter: scale => T.settings.labels.uiScale.scales[scale.id],
+ category: enumCategories.userInterface,
+ restartRequired: false,
+ changeCb:
+ /**
+ * @param {Application} app
+ */
+ (app, id) => app.updateAfterUiScaleChanged(),
+ }),
+
+ new RangeSetting(
+ "soundVolume",
+ enumCategories.general,
/**
* @param {Application} app
*/
- (app, id) => app.updateAfterUiScaleChanged(),
- }),
-
- new RangeSetting(
- "soundVolume",
- enumCategories.general,
- /**
- * @param {Application} app
- */
- (app, value) => app.sound.setSoundVolume(value)
- ),
- new RangeSetting(
- "musicVolume",
- enumCategories.general,
- /**
- * @param {Application} app
- */
- (app, value) => app.sound.setMusicVolume(value)
- ),
-
- new BoolSetting(
- "fullscreen",
- enumCategories.general,
- /**
- * @param {Application} app
- */
- (app, value) => {
- if (app.platformWrapper.getSupportsFullscreen()) {
- app.platformWrapper.setFullscreen(value);
- }
- },
- /**
- * @param {Application} app
- */ app => app.restrictionMgr.getHasExtendedSettings()
- ),
+ (app, value) => app.sound.setSoundVolume(value)
+ ),
+ new RangeSetting(
+ "musicVolume",
+ enumCategories.general,
+ /**
+ * @param {Application} app
+ */
+ (app, value) => app.sound.setMusicVolume(value)
+ ),
- new BoolSetting(
- "enableColorBlindHelper",
- enumCategories.general,
- /**
- * @param {Application} app
- */
- (app, value) => null
- ),
-
- new BoolSetting("offerHints", enumCategories.userInterface, (app, value) => {}),
-
- new EnumSetting("theme", {
- options: Object.keys(THEMES),
- valueGetter: theme => theme,
- textGetter: theme => T.settings.labels.theme.themes[theme],
- category: enumCategories.userInterface,
- restartRequired: false,
- changeCb:
+ new BoolSetting(
+ "fullscreen",
+ enumCategories.general,
/**
* @param {Application} app
*/
- (app, id) => {
- applyGameTheme(id);
- document.documentElement.setAttribute("data-theme", id);
+ (app, value) => {
+ if (app.platformWrapper.getSupportsFullscreen()) {
+ app.platformWrapper.setFullscreen(value);
+ }
},
- enabledCb: /**
- * @param {Application} app
- */ app => app.restrictionMgr.getHasExtendedSettings(),
- }),
-
- new EnumSetting("autosaveInterval", {
- options: autosaveIntervals,
- valueGetter: interval => interval.id,
- textGetter: interval => T.settings.labels.autosaveInterval.intervals[interval.id],
- category: enumCategories.advanced,
- restartRequired: false,
- changeCb:
/**
* @param {Application} app
- */
- (app, id) => null,
- }),
-
- new EnumSetting("scrollWheelSensitivity", {
- options: scrollWheelSensitivities.sort((a, b) => a.scale - b.scale),
- valueGetter: scale => scale.id,
- textGetter: scale => T.settings.labels.scrollWheelSensitivity.sensitivity[scale.id],
- category: enumCategories.advanced,
- restartRequired: false,
- changeCb:
+ */ app => app.restrictionMgr.getHasExtendedSettings()
+ ),
+
+ new BoolSetting(
+ "enableColorBlindHelper",
+ enumCategories.general,
/**
* @param {Application} app
*/
- (app, id) => app.updateAfterUiScaleChanged(),
- }),
-
- new EnumSetting("movementSpeed", {
- options: movementSpeeds.sort((a, b) => a.multiplier - b.multiplier),
- valueGetter: multiplier => multiplier.id,
- textGetter: multiplier => T.settings.labels.movementSpeed.speeds[multiplier.id],
- category: enumCategories.advanced,
- restartRequired: false,
- changeCb: (app, id) => {},
- }),
-
- new BoolSetting("enableMousePan", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("shapeTooltipAlwaysOn", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("alwaysMultiplace", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("zoomToCursor", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("clearCursorOnDeleteWhilePlacing", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("enableTunnelSmartplace", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("vignette", enumCategories.userInterface, (app, value) => {}),
- new BoolSetting("compactBuildingInfo", enumCategories.userInterface, (app, value) => {}),
- new BoolSetting("disableCutDeleteWarnings", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("rotationByBuilding", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("displayChunkBorders", enumCategories.advanced, (app, value) => {}),
- new BoolSetting("pickMinerOnPatch", enumCategories.advanced, (app, value) => {}),
- new RangeSetting("mapResourcesScale", enumCategories.advanced, () => null),
-
- new EnumSetting("refreshRate", {
- options: refreshRateOptions,
- valueGetter: rate => rate,
- textGetter: rate => T.settings.tickrateHz.replace("", rate),
- category: enumCategories.performance,
- restartRequired: false,
- changeCb: (app, id) => {},
- enabledCb: /**
- * @param {Application} app
- */ app => app.restrictionMgr.getHasExtendedSettings(),
- }),
-
- new BoolSetting("lowQualityMapResources", enumCategories.performance, (app, value) => {}),
- new BoolSetting("disableTileGrid", enumCategories.performance, (app, value) => {}),
- new BoolSetting("lowQualityTextures", enumCategories.performance, (app, value) => {}),
- new BoolSetting("simplifiedBelts", enumCategories.performance, (app, value) => {}),
-];
-
-export function getApplicationSettingById(id) {
- return allApplicationSettings.find(setting => setting.id === id);
+ (app, value) => null
+ ),
+
+ new BoolSetting("offerHints", enumCategories.userInterface, (app, value) => {}),
+
+ new EnumSetting("theme", {
+ options: Object.keys(THEMES),
+ valueGetter: theme => theme,
+ textGetter: theme => T.settings.labels.theme.themes[theme],
+ category: enumCategories.userInterface,
+ restartRequired: false,
+ changeCb:
+ /**
+ * @param {Application} app
+ */
+ (app, id) => {
+ applyGameTheme(id);
+ document.documentElement.setAttribute("data-theme", id);
+ },
+ enabledCb: /**
+ * @param {Application} app
+ */ app => app.restrictionMgr.getHasExtendedSettings(),
+ }),
+
+ new EnumSetting("autosaveInterval", {
+ options: autosaveIntervals,
+ valueGetter: interval => interval.id,
+ textGetter: interval => T.settings.labels.autosaveInterval.intervals[interval.id],
+ category: enumCategories.advanced,
+ restartRequired: false,
+ changeCb:
+ /**
+ * @param {Application} app
+ */
+ (app, id) => null,
+ }),
+
+ new EnumSetting("scrollWheelSensitivity", {
+ options: scrollWheelSensitivities.sort((a, b) => a.scale - b.scale),
+ valueGetter: scale => scale.id,
+ textGetter: scale => T.settings.labels.scrollWheelSensitivity.sensitivity[scale.id],
+ category: enumCategories.advanced,
+ restartRequired: false,
+ changeCb:
+ /**
+ * @param {Application} app
+ */
+ (app, id) => app.updateAfterUiScaleChanged(),
+ }),
+
+ new EnumSetting("movementSpeed", {
+ options: movementSpeeds.sort((a, b) => a.multiplier - b.multiplier),
+ valueGetter: multiplier => multiplier.id,
+ textGetter: multiplier => T.settings.labels.movementSpeed.speeds[multiplier.id],
+ category: enumCategories.advanced,
+ restartRequired: false,
+ changeCb: (app, id) => {},
+ }),
+
+ new BoolSetting("enableMousePan", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("shapeTooltipAlwaysOn", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("alwaysMultiplace", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("zoomToCursor", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("clearCursorOnDeleteWhilePlacing", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("enableTunnelSmartplace", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("vignette", enumCategories.userInterface, (app, value) => {}),
+ new BoolSetting("compactBuildingInfo", enumCategories.userInterface, (app, value) => {}),
+ new BoolSetting("disableCutDeleteWarnings", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("rotationByBuilding", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("displayChunkBorders", enumCategories.advanced, (app, value) => {}),
+ new BoolSetting("pickMinerOnPatch", enumCategories.advanced, (app, value) => {}),
+ new RangeSetting("mapResourcesScale", enumCategories.advanced, () => null),
+
+ new EnumSetting("refreshRate", {
+ options: refreshRateOptions,
+ valueGetter: rate => rate,
+ textGetter: rate => T.settings.tickrateHz.replace("", rate),
+ category: enumCategories.performance,
+ restartRequired: false,
+ changeCb: (app, id) => {},
+ enabledCb: /**
+ * @param {Application} app
+ */ app => app.restrictionMgr.getHasExtendedSettings(),
+ }),
+
+ new BoolSetting("lowQualityMapResources", enumCategories.performance, (app, value) => {}),
+ new BoolSetting("disableTileGrid", enumCategories.performance, (app, value) => {}),
+ new BoolSetting("lowQualityTextures", enumCategories.performance, (app, value) => {}),
+ new BoolSetting("simplifiedBelts", enumCategories.performance, (app, value) => {}),
+ ];
}
class SettingsStorage {
@@ -339,6 +337,8 @@ class SettingsStorage {
export class ApplicationSettings extends ReadWriteProxy {
constructor(app) {
super(app, "app_settings.bin");
+
+ this.settingHandles = initializeSettings();
}
initialize() {
@@ -347,8 +347,8 @@ export class ApplicationSettings extends ReadWriteProxy {
.then(() => {
// Apply default setting callbacks
const settings = this.getAllSettings();
- for (let i = 0; i < allApplicationSettings.length; ++i) {
- const handle = allApplicationSettings[i];
+ for (let i = 0; i < this.settingHandles.length; ++i) {
+ const handle = this.settingHandles[i];
handle.apply(this.app, settings[handle.id]);
}
})
@@ -360,6 +360,10 @@ export class ApplicationSettings extends ReadWriteProxy {
return this.writeAsync();
}
+ getSettingHandleById(id) {
+ return this.settingHandles.find(setting => setting.id === id);
+ }
+
// Getters
/**
@@ -457,20 +461,18 @@ export class ApplicationSettings extends ReadWriteProxy {
* @param {string|boolean|number} value
*/
updateSetting(key, value) {
- for (let i = 0; i < allApplicationSettings.length; ++i) {
- const setting = allApplicationSettings[i];
- if (setting.id === key) {
- if (!setting.validate(value)) {
- assertAlways(false, "Bad setting value: " + key);
- }
- this.getAllSettings()[key] = value;
- if (setting.changeCb) {
- setting.changeCb(this.app, value);
- }
- return this.writeAsync();
- }
+ const setting = this.getSettingHandleById(key);
+ if (!setting) {
+ assertAlways(false, "Unknown setting: " + key);
+ }
+ if (!setting.validate(value)) {
+ assertAlways(false, "Bad setting value: " + key);
+ }
+ this.getAllSettings()[key] = value;
+ if (setting.changeCb) {
+ setting.changeCb(this.app, value);
}
- assertAlways(false, "Unknown setting: " + key);
+ return this.writeAsync();
}
/**
@@ -510,8 +512,15 @@ export class ApplicationSettings extends ReadWriteProxy {
}
const settings = data.settings;
- for (let i = 0; i < allApplicationSettings.length; ++i) {
- const setting = allApplicationSettings[i];
+
+ // MODS
+ if (!THEMES[settings.theme]) {
+ console.warn("Resetting theme because its no longer available: " + settings.theme);
+ settings.theme = "light";
+ }
+
+ for (let i = 0; i < this.settingHandles.length; ++i) {
+ const setting = this.settingHandles[i];
const storedValue = settings[setting.id];
if (!setting.validate(storedValue)) {
return ExplainedResult.bad(
@@ -690,6 +699,12 @@ export class ApplicationSettings extends ReadWriteProxy {
data.version = 31;
}
+ // MODS
+ if (!THEMES[data.settings.theme]) {
+ console.warn("Resetting theme because its no longer available: " + data.settings.theme);
+ data.settings.theme = "light";
+ }
+
return ExplainedResult.good();
}
}
diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js
index 36ed884f..b4472b2b 100644
--- a/src/js/savegame/savegame.js
+++ b/src/js/savegame/savegame.js
@@ -14,6 +14,8 @@ import { SavegameInterface_V1006 } from "./schemas/1006";
import { SavegameInterface_V1007 } from "./schemas/1007";
import { SavegameInterface_V1008 } from "./schemas/1008";
import { SavegameInterface_V1009 } from "./schemas/1009";
+import { MODS } from "../mods/modloader";
+import { SavegameInterface_V1010 } from "./schemas/1010";
const logger = createLogger("savegame");
@@ -54,7 +56,7 @@ export class Savegame extends ReadWriteProxy {
* @returns {number}
*/
static getCurrentVersion() {
- return 1009;
+ return 1010;
}
/**
@@ -103,6 +105,7 @@ export class Savegame extends ReadWriteProxy {
usedInverseRotater: false,
},
lastUpdate: Date.now(),
+ mods: MODS.getModsListForSavegame(),
};
}
@@ -160,6 +163,11 @@ export class Savegame extends ReadWriteProxy {
data.version = 1009;
}
+ if (data.version === 1009) {
+ SavegameInterface_V1010.migrate1009to1010(data);
+ data.version = 1010;
+ }
+
return ExplainedResult.good();
}
@@ -269,6 +277,7 @@ export class Savegame extends ReadWriteProxy {
shadowData.dump = dump;
shadowData.lastUpdate = new Date().getTime();
shadowData.version = this.getCurrentVersion();
+ shadowData.mods = MODS.getModsListForSavegame();
const reader = this.getDumpReaderForExternalData(shadowData);
diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js
index b4dc4233..089b15fc 100644
--- a/src/js/savegame/savegame_interface_registry.js
+++ b/src/js/savegame/savegame_interface_registry.js
@@ -10,6 +10,7 @@ import { SavegameInterface_V1006 } from "./schemas/1006";
import { SavegameInterface_V1007 } from "./schemas/1007";
import { SavegameInterface_V1008 } from "./schemas/1008";
import { SavegameInterface_V1009 } from "./schemas/1009";
+import { SavegameInterface_V1010 } from "./schemas/1010";
/** @type {Object.} */
export const savegameInterfaces = {
@@ -23,6 +24,7 @@ export const savegameInterfaces = {
1007: SavegameInterface_V1007,
1008: SavegameInterface_V1008,
1009: SavegameInterface_V1009,
+ 1010: SavegameInterface_V1010,
};
const logger = createLogger("savegame_interface_registry");
diff --git a/src/js/savegame/savegame_serializer.js b/src/js/savegame/savegame_serializer.js
index 3230cdd5..f95c9896 100644
--- a/src/js/savegame/savegame_serializer.js
+++ b/src/js/savegame/savegame_serializer.js
@@ -1,9 +1,8 @@
import { ExplainedResult } from "../core/explained_result";
-import { createLogger } from "../core/logging";
import { gComponentRegistry } from "../core/global_registries";
+import { createLogger } from "../core/logging";
+import { MOD_SIGNALS } from "../mods/mod_signals";
import { SerializerInternal } from "./serializer_internal";
-import { HUDPinnedShapes } from "../game/hud/parts/pinned_shapes";
-import { HUDWaypoints } from "../game/hud/parts/waypoints";
/**
* @typedef {import("../game/component").Component} Component
@@ -42,8 +41,12 @@ export class SavegameSerializer {
beltPaths: root.systemMgr.systems.belt.serializePaths(),
pinnedShapes: root.hud.parts.pinnedShapes ? root.hud.parts.pinnedShapes.serialize() : null,
waypoints: root.hud.parts.waypoints ? root.hud.parts.waypoints.serialize() : null,
+
+ modExtraData: {},
};
+ MOD_SIGNALS.gameSerialized.dispatch(root, data);
+
if (G_IS_DEV) {
if (sanityChecks) {
// Sanity check
@@ -151,6 +154,9 @@ export class SavegameSerializer {
return ExplainedResult.bad(errorReason);
}
+ // Mods
+ MOD_SIGNALS.gameDeserialized.dispatch(root, savegame);
+
return ExplainedResult.good();
}
}
diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js
index c5e0e5c5..b1980115 100644
--- a/src/js/savegame/savegame_typedefs.js
+++ b/src/js/savegame/savegame_typedefs.js
@@ -2,6 +2,14 @@
* @typedef {import("../game/entity").Entity} Entity
*
* @typedef {{
+ * id: string;
+ * version: string;
+ * website: string;
+ * name: string;
+ * author: string;
+ * }[]} SavegameStoredMods
+ *
+ * @typedef {{
* failedMam: boolean,
* trashedCount: number,
* usedInverseRotater: boolean
@@ -17,7 +25,8 @@
* pinnedShapes: any,
* waypoints: any,
* entities: Array,
- * beltPaths: Array
+ * beltPaths: Array,
+ * modExtraData: Object
* }} SerializedGame
*
* @typedef {{
@@ -25,6 +34,7 @@
* dump: SerializedGame,
* stats: SavegameStats,
* lastUpdate: number,
+ * mods: SavegameStoredMods
* }} SavegameData
*
* @typedef {{
diff --git a/src/js/savegame/schemas/1010.js b/src/js/savegame/schemas/1010.js
new file mode 100644
index 00000000..8f480800
--- /dev/null
+++ b/src/js/savegame/schemas/1010.js
@@ -0,0 +1,28 @@
+import { createLogger } from "../../core/logging.js";
+import { SavegameInterface_V1009 } from "./1009.js";
+
+const schema = require("./1010.json");
+const logger = createLogger("savegame_interface/1010");
+
+export class SavegameInterface_V1010 extends SavegameInterface_V1009 {
+ getVersion() {
+ return 1010;
+ }
+
+ getSchemaUncached() {
+ return schema;
+ }
+
+ /**
+ * @param {import("../savegame_typedefs.js").SavegameData} data
+ */
+ static migrate1009to1010(data) {
+ logger.log("Migrating 1009 to 1010");
+
+ data.mods = [];
+
+ if (data.dump) {
+ data.dump.modExtraData = {};
+ }
+ }
+}
diff --git a/src/js/savegame/schemas/1010.json b/src/js/savegame/schemas/1010.json
new file mode 100644
index 00000000..6682f615
--- /dev/null
+++ b/src/js/savegame/schemas/1010.json
@@ -0,0 +1,5 @@
+{
+ "type": "object",
+ "required": [],
+ "additionalProperties": true
+}
diff --git a/src/js/savegame/serialization.js b/src/js/savegame/serialization.js
index 78642ceb..770f166f 100644
--- a/src/js/savegame/serialization.js
+++ b/src/js/savegame/serialization.js
@@ -22,6 +22,7 @@ import {
TypeString,
TypeStructuredObject,
TypeVector,
+ TypePositiveIntegerOrString,
} from "./serialization_data_types";
const logger = createLogger("serialization");
@@ -38,6 +39,7 @@ export const types = {
vector: new TypeVector(),
tileVector: new TypeVector(),
bool: new TypeBoolean(),
+ uintOrString: new TypePositiveIntegerOrString(),
/**
* @param {BaseDataType} wrapped
@@ -136,7 +138,7 @@ export const types = {
/**
* A full schema declaration
- * @typedef {Object.} Schema
+ * @typedef {Object. | object} Schema
*/
const globalSchemaCache = {};
diff --git a/src/js/savegame/serialization_data_types.js b/src/js/savegame/serialization_data_types.js
index 9d3b689f..df352e78 100644
--- a/src/js/savegame/serialization_data_types.js
+++ b/src/js/savegame/serialization_data_types.js
@@ -213,6 +213,53 @@ export class TypePositiveInteger extends BaseDataType {
}
}
+export class TypePositiveIntegerOrString extends BaseDataType {
+ serialize(value) {
+ if (Number.isInteger(value)) {
+ assert(value >= 0, "type integer got negative value: " + value);
+ } else if (typeof value === "string") {
+ // all good
+ } else {
+ assertAlways(false, "Type integer|string got non integer or string for serialize: " + value);
+ }
+ return value;
+ }
+
+ /**
+ * @see BaseDataType.deserialize
+ * @param {any} value
+ * @param {GameRoot} root
+ * @param {object} targetObject
+ * @param {string|number} targetKey
+ * @returns {string|void} String error code or null on success
+ */
+ deserialize(value, targetObject, targetKey, root) {
+ targetObject[targetKey] = value;
+ }
+
+ getAsJsonSchemaUncached() {
+ return {
+ oneOf: [{ type: "integer", minimum: 0 }, { type: "string" }],
+ };
+ }
+
+ verifySerializedValue(value) {
+ if (Number.isInteger(value)) {
+ if (value < 0) {
+ return "Negative value for positive integer";
+ }
+ } else if (typeof value === "string") {
+ // all good
+ } else {
+ return "Not a valid number or string: " + value;
+ }
+ }
+
+ getCacheKey() {
+ return "uint_str";
+ }
+}
+
export class TypeBoolean extends BaseDataType {
serialize(value) {
assert(value === true || value === false, "Type bool got non bool for serialize: " + value);
diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js
index 0dd6c72a..108028a4 100644
--- a/src/js/states/ingame.js
+++ b/src/js/states/ingame.js
@@ -9,24 +9,25 @@ import { Savegame } from "../savegame/savegame";
import { GameCore } from "../game/core";
import { MUSIC } from "../platform/sound";
import { enumGameModeIds } from "../game/game_mode";
+import { MOD_SIGNALS } from "../mods/mod_signals";
const logger = createLogger("state/ingame");
// Different sub-states
-const stages = {
- s3_createCore: "🌈 3: Create core",
- s4_A_initEmptyGame: "🌈 4/A: Init empty game",
- s4_B_resumeGame: "🌈 4/B: Resume game",
+export const GAME_LOADING_STATES = {
+ s3_createCore: "s3_createCore",
+ s4_A_initEmptyGame: "s4_A_initEmptyGame",
+ s4_B_resumeGame: "s4_B_resumeGame",
- s5_firstUpdate: "🌈 5: First game update",
- s6_postLoadHook: "🌈 6: Post load hook",
- s7_warmup: "🌈 7: Warmup",
+ s5_firstUpdate: "s5_firstUpdate",
+ s6_postLoadHook: "s6_postLoadHook",
+ s7_warmup: "s7_warmup",
- s10_gameRunning: "🌈 10: Game finally running",
+ s10_gameRunning: "s10_gameRunning",
- leaving: "🌈 Saving, then leaving the game",
- destroyed: "🌈 DESTROYED: Core is empty and waits for state leave",
- initFailed: "🌈 ERROR: Initialization failed!",
+ leaving: "leaving",
+ destroyed: "destroyed",
+ initFailed: "initFailed",
};
export const gameCreationAction = {
@@ -82,6 +83,10 @@ export class InGameState extends GameState {
this.currentSavePromise = null;
}
+ get dialogs() {
+ return this.core.root.hud.parts.dialogs;
+ }
+
/**
* Switches the game into another sub-state
* @param {string} stage
@@ -91,6 +96,7 @@ export class InGameState extends GameState {
if (stage !== this.stage) {
this.stage = stage;
logger.log(this.stage);
+ MOD_SIGNALS.gameLoadingStageEntered.dispatch(this, stage);
return true;
} else {
// log(this, "Re entering", stage);
@@ -146,7 +152,7 @@ export class InGameState extends GameState {
onResized(w, h) {
super.onResized(w, h);
- if (this.stage === stages.s10_gameRunning) {
+ if (this.stage === GAME_LOADING_STATES.s10_gameRunning) {
this.core.resize(w, h);
}
}
@@ -190,7 +196,7 @@ export class InGameState extends GameState {
* @param {any=} payload
*/
saveThenGoToState(stateId, payload) {
- if (this.stage === stages.leaving || this.stage === stages.destroyed) {
+ if (this.stage === GAME_LOADING_STATES.leaving || this.stage === GAME_LOADING_STATES.destroyed) {
logger.warn(
"Tried to leave game twice or during destroy:",
this.stage,
@@ -217,7 +223,7 @@ export class InGameState extends GameState {
* @param {string} err
*/
onInitializationFailure(err) {
- if (this.switchStage(stages.initFailed)) {
+ if (this.switchStage(GAME_LOADING_STATES.initFailed)) {
logger.error("Init failure:", err);
this.stageDestroyed();
this.moveToState("MainMenuState", { loadError: err });
@@ -230,7 +236,7 @@ export class InGameState extends GameState {
* Creates the game core instance, and thus the root
*/
stage3CreateCore() {
- if (this.switchStage(stages.s3_createCore)) {
+ if (this.switchStage(GAME_LOADING_STATES.s3_createCore)) {
logger.log("Creating new game core");
this.core = new GameCore(this.app);
@@ -249,7 +255,7 @@ export class InGameState extends GameState {
* Initializes a new empty game
*/
stage4aInitEmptyGame() {
- if (this.switchStage(stages.s4_A_initEmptyGame)) {
+ if (this.switchStage(GAME_LOADING_STATES.s4_A_initEmptyGame)) {
this.core.initNewGame();
this.stage5FirstUpdate();
}
@@ -259,7 +265,7 @@ export class InGameState extends GameState {
* Resumes an existing game
*/
stage4bResumeGame() {
- if (this.switchStage(stages.s4_B_resumeGame)) {
+ if (this.switchStage(GAME_LOADING_STATES.s4_B_resumeGame)) {
if (!this.core.initExistingGame()) {
this.onInitializationFailure("Savegame is corrupt and can not be restored.");
return;
@@ -273,7 +279,7 @@ export class InGameState extends GameState {
* Performs the first game update on the game which initializes most caches
*/
stage5FirstUpdate() {
- if (this.switchStage(stages.s5_firstUpdate)) {
+ if (this.switchStage(GAME_LOADING_STATES.s5_firstUpdate)) {
this.core.root.logicInitialized = true;
this.core.updateLogic();
this.stage6PostLoadHook();
@@ -285,7 +291,7 @@ export class InGameState extends GameState {
* can operate and start to work now.
*/
stage6PostLoadHook() {
- if (this.switchStage(stages.s6_postLoadHook)) {
+ if (this.switchStage(GAME_LOADING_STATES.s6_postLoadHook)) {
logger.log("Post load hook");
this.core.postLoadHook();
this.stage7Warmup();
@@ -298,7 +304,7 @@ export class InGameState extends GameState {
* are in the VRAM and we have a smooth experience once we start.
*/
stage7Warmup() {
- if (this.switchStage(stages.s7_warmup)) {
+ if (this.switchStage(GAME_LOADING_STATES.s7_warmup)) {
if (this.creationPayload.fastEnter) {
this.warmupTimeSeconds = globalConfig.warmupTimeSecondsFast;
} else {
@@ -311,13 +317,15 @@ export class InGameState extends GameState {
* The final stage where this game is running and updating regulary.
*/
stage10GameRunning() {
- if (this.switchStage(stages.s10_gameRunning)) {
+ if (this.switchStage(GAME_LOADING_STATES.s10_gameRunning)) {
this.core.root.signals.readyToRender.dispatch();
logSection("GAME STARTED", "#26a69a");
// Initial resize, might have changed during loading (this is possible)
this.core.resize(this.app.screenWidth, this.app.screenHeight);
+
+ MOD_SIGNALS.gameStarted.dispatch(this.core.root);
}
}
@@ -325,7 +333,7 @@ export class InGameState extends GameState {
* This stage destroys the whole game, used to cleanup
*/
stageDestroyed() {
- if (this.switchStage(stages.destroyed)) {
+ if (this.switchStage(GAME_LOADING_STATES.destroyed)) {
// Cleanup all api calls
this.cancelAllAsyncOperations();
@@ -346,7 +354,7 @@ export class InGameState extends GameState {
* When leaving the game
*/
stageLeavingGame() {
- if (this.switchStage(stages.leaving)) {
+ if (this.switchStage(GAME_LOADING_STATES.leaving)) {
// ...
}
}
@@ -357,7 +365,7 @@ export class InGameState extends GameState {
* Filters the input (keybindings)
*/
filterInput() {
- return this.stage === stages.s10_gameRunning;
+ return this.stage === GAME_LOADING_STATES.s10_gameRunning;
}
/**
@@ -395,7 +403,7 @@ export class InGameState extends GameState {
return;
}
- if (this.stage === stages.s7_warmup) {
+ if (this.stage === GAME_LOADING_STATES.s7_warmup) {
this.core.draw();
this.warmupTimeSeconds -= dt / 1000.0;
if (this.warmupTimeSeconds < 0) {
@@ -404,12 +412,12 @@ export class InGameState extends GameState {
}
}
- if (this.stage === stages.s10_gameRunning) {
+ if (this.stage === GAME_LOADING_STATES.s10_gameRunning) {
this.core.tick(dt);
}
// If the stage is still active (This might not be the case if tick() moved us to game over)
- if (this.stage === stages.s10_gameRunning) {
+ if (this.stage === GAME_LOADING_STATES.s10_gameRunning) {
// Only draw if page visible
if (this.app.pageVisible) {
this.core.draw();
@@ -442,9 +450,9 @@ export class InGameState extends GameState {
}
if (
- this.stage !== stages.s10_gameRunning &&
- this.stage !== stages.s7_warmup &&
- this.stage !== stages.leaving
+ this.stage !== GAME_LOADING_STATES.s10_gameRunning &&
+ this.stage !== GAME_LOADING_STATES.s7_warmup &&
+ this.stage !== GAME_LOADING_STATES.leaving
) {
logger.warn("Skipping save because game is not ready");
return Promise.resolve();
diff --git a/src/js/states/keybindings.js b/src/js/states/keybindings.js
index a01629f1..e6721bf8 100644
--- a/src/js/states/keybindings.js
+++ b/src/js/states/keybindings.js
@@ -19,7 +19,7 @@ export class KeybindingsState extends TextualGameState {
${T.keybindings.hint}
${T.keybindings.resetKeybindings}
-
+
@@ -34,6 +34,10 @@ export class KeybindingsState extends TextualGameState {
this.trackClicks(this.htmlElement.querySelector(".resetBindings"), this.resetBindings);
for (const category in KEYMAPPINGS) {
+ if (Object.keys(KEYMAPPINGS[category]).length === 0) {
+ continue;
+ }
+
const categoryDiv = document.createElement("div");
categoryDiv.classList.add("category");
keybindingsElem.appendChild(categoryDiv);
@@ -138,7 +142,19 @@ export class KeybindingsState extends TextualGameState {
}
const mappingDiv = container.querySelector(".mapping");
- mappingDiv.innerHTML = getStringForKeyCode(keyCode);
+ let modifiers = "";
+
+ if (mapped.modifiers && mapped.modifiers.shift) {
+ modifiers += "⇪ ";
+ }
+ if (mapped.modifiers && mapped.modifiers.alt) {
+ modifiers += T.global.keys.alt + " ";
+ }
+ if (mapped.modifiers && mapped.modifiers.ctrl) {
+ modifiers += T.global.keys.control + " ";
+ }
+
+ mappingDiv.innerHTML = modifiers + getStringForKeyCode(keyCode);
mappingDiv.classList.toggle("changed", !!overrides[keybindingId]);
const resetBtn = container.querySelector("button.resetKeybinding");
diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js
index 60495a9c..10e280b9 100644
--- a/src/js/states/main_menu.js
+++ b/src/js/states/main_menu.js
@@ -10,16 +10,17 @@ import {
generateFileDownload,
isSupportedBrowser,
makeButton,
- makeButtonElement,
makeDiv,
+ makeDivElement,
removeAllChildren,
startFileChoose,
waitNextFrame,
} from "../core/utils";
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
+import { MODS } from "../mods/modloader";
import { PlatformWrapperImplBrowser } from "../platform/browser/wrapper";
import { PlatformWrapperImplElectron } from "../platform/electron/wrapper";
-import { getApplicationSettingById } from "../profile/application_settings";
+import { Savegame } from "../savegame/savegame";
import { T } from "../translations";
const trim = require("trim");
@@ -41,6 +42,7 @@ export class MainMenuState extends GameState {
const showBrowserWarning = !G_IS_STANDALONE && !isSupportedBrowser();
const showPuzzleDLC = !G_WEGAME_VERSION && (G_IS_STANDALONE || G_IS_DEV);
const showWegameFooter = G_WEGAME_VERSION;
+ const hasMods = MODS.anyModsActive();
let showExternalLinks = true;
@@ -94,7 +96,7 @@ export class MainMenuState extends GameState {
- ${showUpdateLabel ? `
v${G_BUILD_VERSION}! ` : ""}
+ ${showUpdateLabel ? `
MODS UPDATE! ` : ""}
@@ -112,7 +114,7 @@ export class MainMenuState extends GameState {
${
- showPuzzleDLC && ownsPuzzleDLC
+ showPuzzleDLC && ownsPuzzleDLC && !hasMods
? `
@@ -147,6 +149,38 @@ export class MainMenuState extends GameState {
`
: ""
}
+ ${
+ hasMods
+ ? `
+
+
+
+
+ ${MODS.mods
+ .map(mod => {
+ return `
+
+
${mod.metadata.name}
+
by ${mod.metadata.author}
+
+ `;
+ })
+ .join("")}
+
+
+
+ ${T.mainMenu.mods.warningPuzzleDLC}
+
+
+
+
+ `
+ : ""
+ }
+
${
@@ -195,7 +229,7 @@ export class MainMenuState extends GameState {