diff --git a/gulp/mod.js b/gulp/mod.js new file mode 100644 index 00000000..87912bb3 --- /dev/null +++ b/gulp/mod.js @@ -0,0 +1,3 @@ +module.exports = function (source, map) { + return source + `\nexport let $s=(n,v)=>eval(n+"=v")`; +}; diff --git a/gulp/webpack.config.js b/gulp/webpack.config.js index 7db0bf0b..3c07d7cc 100644 --- a/gulp/webpack.config.js +++ b/gulp/webpack.config.js @@ -93,6 +93,9 @@ module.exports = ({ watch = false, standalone = false, chineseVersion = false, w end: "typehints:end", }, }, + { + loader: path.resolve(__dirname, "mod.js"), + }, ], }, { diff --git a/mod_examples/README.md b/mod_examples/README.md index e38d5834..579751f9 100644 --- a/mod_examples/README.md +++ b/mod_examples/README.md @@ -11,7 +11,7 @@ Since the `create-shapezio-mod` package is still in development, the current rec ## Mod Developer Discord -A great place to get help with mod development is the official [shapez.io modloader discord]https://discord.gg/xq5v8uyMue). +A great place to get help with mod development is the official [shapez.io modloader discord](https://discord.gg/xq5v8uyMue). ## Setting up your development environment @@ -38,6 +38,7 @@ To get into shapez.io modding, I highly recommend checking out all of the exampl | [modify_theme.js](modify_theme.js) | Modifies the default game themes | Modifying the builtin themes | | [custom_theme.js](custom_theme.js) | Adds a new UI and map theme | Adding a new game theme | | [mod_settings.js](mod_settings.js) | Shows a dialog counting how often the mod has been launched | Reading and storing mod settings | +| [storing_data_in_savegame.js](storing_data_in_savegame.js) | Shows how to store custom (structured) data in the savegame | Storing custom data in savegame | | [modify_existing_building.js](modify_existing_building.js) | Makes the rotator building always unlocked and adds a new statistic to the building panel | Modifying a builtin building, replacing builtin methods | | [modify_ui.js](modify_ui.js) | Shows how to add custom IU elements to builtin game states (the Main Menu in this case) | Extending builtin UI states, Adding CSS | | [pasting.js](pasting.js) | Shows a dialog when pasting text in the game | Listening to paste events | diff --git a/mod_examples/base.js b/mod_examples/base.js index 09e12f61..f76ded9b 100644 --- a/mod_examples/base.js +++ b/mod_examples/base.js @@ -7,6 +7,10 @@ const METADATA = { id: "base", description: "The most basic mod", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/custom_css.js b/mod_examples/custom_css.js index dce316c5..0d28fda7 100644 --- a/mod_examples/custom_css.js +++ b/mod_examples/custom_css.js @@ -7,6 +7,10 @@ const METADATA = { id: "custom-css", description: "Shows how to add custom css", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/custom_drawing.js b/mod_examples/custom_drawing.js index e1c25b30..2dccab2d 100644 --- a/mod_examples/custom_drawing.js +++ b/mod_examples/custom_drawing.js @@ -7,6 +7,10 @@ const METADATA = { id: "base", description: "Displays an indicator on every item processing building when its working", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class ItemProcessorStatusGameSystem extends shapez.GameSystem { diff --git a/mod_examples/custom_keybinding.js b/mod_examples/custom_keybinding.js index 0a6b11fc..0109833c 100644 --- a/mod_examples/custom_keybinding.js +++ b/mod_examples/custom_keybinding.js @@ -7,6 +7,10 @@ const METADATA = { id: "base", description: "Shows how to add a new keybinding", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/custom_theme.js b/mod_examples/custom_theme.js index cc4c9de8..a70d949f 100644 --- a/mod_examples/custom_theme.js +++ b/mod_examples/custom_theme.js @@ -7,6 +7,10 @@ const METADATA = { id: "custom-theme", description: "Shows how to add a custom game theme", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/modify_theme.js b/mod_examples/modify_theme.js index 4bc9be34..de7f0ad2 100644 --- a/mod_examples/modify_theme.js +++ b/mod_examples/modify_theme.js @@ -7,6 +7,10 @@ const METADATA = { id: "modify-theme", description: "Shows how to modify builtin themes", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/modify_ui.js b/mod_examples/modify_ui.js index 0b2d1341..4beb403d 100644 --- a/mod_examples/modify_ui.js +++ b/mod_examples/modify_ui.js @@ -7,6 +7,10 @@ const METADATA = { id: "modify-ui", description: "Shows how to modify a builtin game state, in this case the main menu", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/replace_builtin_sprites.js b/mod_examples/replace_builtin_sprites.js index 885846e7..6e88b7fb 100644 --- a/mod_examples/replace_builtin_sprites.js +++ b/mod_examples/replace_builtin_sprites.js @@ -7,6 +7,10 @@ const METADATA = { id: "replace-builtin-sprites", description: "Shows how to replace builtin sprites", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/mod_examples/storing_data_in_savegame.js b/mod_examples/storing_data_in_savegame.js new file mode 100644 index 00000000..92f7733b --- /dev/null +++ b/mod_examples/storing_data_in_savegame.js @@ -0,0 +1,78 @@ +// @ts-nocheck +const METADATA = { + website: "https://tobspr.io", + author: "tobspr", + name: "Mod Example: Storing Data in Savegame", + version: "1", + id: "storing-savegame-data", + description: "Shows how to add custom data to a savegame", + minimumGameVersion: ">=1.5.0", +}; + +class Mod extends shapez.Mod { + init() { + //////////////////////////////////////////////////////////////////// + // Option 1: For simple data + this.signals.gameSerialized.add((root, data) => { + data.modExtraData["storing-savegame-data"] = Math.random(); + }); + + this.signals.gameDeserialized.add((root, data) => { + alert("The value stored in the savegame was: " + data.modExtraData["storing-savegame-data"]); + }); + + //////////////////////////////////////////////////////////////////// + // Option 2: If you need a structured way of storing data + + class SomeSerializableObject extends shapez.BasicSerializableObject { + static getId() { + return "SomeSerializableObject"; + } + + static getSchema() { + return { + someInt: shapez.types.int, + someString: shapez.types.string, + someVector: shapez.types.vector, + + // this value is allowed to be null + nullableInt: shapez.types.nullable(shapez.types.int), + + // There is a lot more .. be sure to checkout src/js/savegame/serialization.js + // You can have maps, classes, arrays etc.. + // And if you need something specific you can always ask in the modding discord. + }; + } + + constructor() { + super(); + this.someInt = 42; + this.someString = "Hello World"; + this.someVector = new shapez.Vector(1, 2); + + this.nullableInt = null; + } + } + + // Store our object in the global game root + this.signals.gameInitialized.add(root => { + root.myObject = new SomeSerializableObject(); + }); + + // Save it within the savegame + this.signals.gameSerialized.add((root, data) => { + data.modExtraData["storing-savegame-data-2"] = root.myObject.serialize(); + }); + + // Restore it when the savegame is loaded + this.signals.gameDeserialized.add((root, data) => { + const errorText = root.myObject.deserialize(data.modExtraData["storing-savegame-data-2"]); + if (errorText) { + alert("Mod failed to deserialize from savegame: " + errorText); + } + alert("The other value stored in the savegame (option 2) was " + root.myObject.someInt); + }); + + //////////////////////////////////////////////////////////////////// + } +} diff --git a/mod_examples/translations.js b/mod_examples/translations.js index 2f3a4015..6b9c708e 100644 --- a/mod_examples/translations.js +++ b/mod_examples/translations.js @@ -7,6 +7,10 @@ const METADATA = { id: "translations", description: "Shows how to add and modify translations", minimumGameVersion: ">=1.5.0", + + // You can specify this parameter if savegames will still work + // after your mod has been uninstalled + doesNotAffectSavegame: true, }; class Mod extends shapez.Mod { diff --git a/src/css/ingame_hud/dialogs.scss b/src/css/ingame_hud/dialogs.scss index cc742d42..6a93ceb7 100644 --- a/src/css/ingame_hud/dialogs.scss +++ b/src/css/ingame_hud/dialogs.scss @@ -169,6 +169,10 @@ margin: 1px 0; } + h3 { + @include S(margin-top, 10px); + } + input { background: #eee; color: #333438; @@ -214,6 +218,33 @@ } } } + + .dialogModsMod { + background: rgba(0, 0, 0, 0.05); + @include S(padding, 5px); + @include S(margin, 10px, 0); + @include S(border-radius, $globalBorderRadius); + display: grid; + grid-template-columns: 1fr D(100px); + + @include DarkThemeOverride { + background: rgba(0, 0, 0, 0.2); + } + + button { + grid-column: 2 / 3; + grid-row: 1 / 3; + align-self: start; + } + + .version { + @include SuperSmallText; + opacity: 0.5; + } + + .name { + } + } } > .buttons { diff --git a/src/js/game/game_system_manager.js b/src/js/game/game_system_manager.js index 0239abf7..d8c16200 100644 --- a/src/js/game/game_system_manager.js +++ b/src/js/game/game_system_manager.js @@ -205,7 +205,7 @@ export class GameSystemManager { addBefore("end"); for (const key in MODS_ADDITIONAL_SYSTEMS) { - if (!this.systems[key]) { + if (!this.systems[key] && key !== "end") { logger.error("Mod system not attached due to invalid 'before': ", key); } } diff --git a/src/js/game/hud/parts/building_placer.js b/src/js/game/hud/parts/building_placer.js index ca9c128b..d2904720 100644 --- a/src/js/game/hud/parts/building_placer.js +++ b/src/js/game/hud/parts/building_placer.js @@ -401,9 +401,10 @@ export class HUDBuildingPlacer extends HUDBuildingPlacerLogic { * Checks if there are any entities in the way, returns true if there are * @param {Vector} from * @param {Vector} to + * @param {Vector[]=} ignorePositions * @returns */ - checkForObstales(from, to) { + checkForObstales(from, to, ignorePositions = []) { assert(from.x === to.x || from.y === to.y, "Must be a straight line"); const prop = from.x === to.x ? "y" : "x"; @@ -426,6 +427,9 @@ export class HUDBuildingPlacer extends HUDBuildingPlacerLogic { for (let i = start; i <= end; i++) { current[prop] = i; + if (ignorePositions.some(p => p.distanceSquare(current) < 0.1)) { + continue; + } if (!this.root.logic.checkCanPlaceEntity(this.fakeEntity, { allowReplaceBuildings: false })) { return true; } @@ -464,8 +468,11 @@ export class HUDBuildingPlacer extends HUDBuildingPlacerLogic { const endLine = mouseTile.toWorldSpaceCenterOfTile(); const midLine = this.currentDirectionLockCorner.toWorldSpaceCenterOfTile(); const anyObstacle = - this.checkForObstales(this.lastDragTile, this.currentDirectionLockCorner) || - this.checkForObstales(this.currentDirectionLockCorner, mouseTile); + this.checkForObstales(this.lastDragTile, this.currentDirectionLockCorner, [ + this.lastDragTile, + mouseTile, + ]) || + this.checkForObstales(this.currentDirectionLockCorner, mouseTile, [this.lastDragTile, mouseTile]); if (anyObstacle) { applyStyles("error"); diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js index 3f700dff..b2e2db74 100644 --- a/src/js/game/systems/item_processor.js +++ b/src/js/game/systems/item_processor.js @@ -96,11 +96,11 @@ export class ItemProcessorSystem extends GameSystemWithFilter { } } - // Check if it finished - if (currentCharge.remainingTime <= 0.0 && processorComp.queuedEjects.length < 1) { + // Check if it finished and we don't already have queued ejects + if (currentCharge.remainingTime <= 0.0 && !processorComp.queuedEjects.length) { const itemsToEject = currentCharge.items; - // Go over all items and try to eject them + // Go over all items and add them to the queue for (let j = 0; j < itemsToEject.length; ++j) { processorComp.queuedEjects.push(itemsToEject[j]); } @@ -133,6 +133,39 @@ export class ItemProcessorSystem extends GameSystemWithFilter { slot = ejectorComp.getFirstFreeSlot(); } + if (slot !== null) { + // Alright, we can actually eject + if (!ejectorComp.tryEject(slot, item)) { + assert(false, "Failed to eject"); + } else { + processorComp.queuedEjects.splice(j, 1); + j -= 1; + } + } + } + } + } + + assert(ejectorComp, "To eject items, the building needs to have an ejector"); + + let slot = null; + if (requiredSlot !== null && requiredSlot !== undefined) { + // We have a slot override, check if that is free + if (ejectorComp.canEjectOnSlot(requiredSlot)) { + slot = requiredSlot; + } + } else if (preferredSlot !== null && preferredSlot !== undefined) { + // We have a slot preference, try using it but otherwise use a free slot + if (ejectorComp.canEjectOnSlot(preferredSlot)) { + slot = preferredSlot; + } else { + slot = ejectorComp.getFirstFreeSlot(); + } + } else { + // We can eject on any slot + slot = ejectorComp.getFirstFreeSlot(); + } + if (slot !== null) { // Alright, we can actually eject if (!ejectorComp.tryEject(slot, item, extraProgress)) { diff --git a/src/js/mods/mod_signals.js b/src/js/mods/mod_signals.js index c311af29..a534dd89 100644 --- a/src/js/mods/mod_signals.js +++ b/src/js/mods/mod_signals.js @@ -27,4 +27,7 @@ export const MOD_SIGNALS = { 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 index 48911abd..a1658abc 100644 --- a/src/js/mods/modloader.js +++ b/src/js/mods/modloader.js @@ -24,7 +24,8 @@ const LOG = createLogger("mods"); * description: string; * id: string; * minimumGameVersion?: string; - * settings: [] + * settings: []; + * doesNotAffectSavegame?: boolean * }} ModMetadata */ @@ -58,6 +59,51 @@ export class ModLoader { 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 = {}; @@ -66,18 +112,20 @@ export class ModLoader { // @ts-ignore const module = modules(key); for (const member in module) { - if (member === "default") { + 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[member] = v; + module["$s"](member, v); }, }); } 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/states/main_menu.js b/src/js/states/main_menu.js index 4d62f2ce..10e280b9 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -20,6 +20,7 @@ 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 { Savegame } from "../savegame/savegame"; import { T } from "../translations"; const trim = require("trim"); @@ -615,11 +616,13 @@ export class MainMenuState extends GameState { const savegame = this.app.savegameMgr.getSavegameById(game.internalId); savegame .readAsync() + .then(() => this.checkForModDifferences(savegame)) .then(() => { this.moveToState("InGameState", { savegame, }); }) + .catch(err => { this.dialogs.showWarning( T.dialogs.gameLoadFailure.title, @@ -629,6 +632,57 @@ export class MainMenuState extends GameState { }); } + /** + * @param {Savegame} savegame + */ + checkForModDifferences(savegame) { + const difference = MODS.computeModDifference(savegame.currentData.mods); + + if (difference.missing.length === 0 && difference.extra.length === 0) { + return Promise.resolve(); + } + + let dialogHtml = T.dialogs.modsDifference.desc; + + /** + * + * @param {import("../savegame/savegame_typedefs").SavegameStoredMods[0]} mod + */ + function formatMod(mod) { + return ` +
+
${mod.name}
+
${T.mods.version} ${mod.version}
+ + +
+ `; + } + + if (difference.missing.length > 0) { + dialogHtml += "

" + T.dialogs.modsDifference.missingMods + "

"; + dialogHtml += difference.missing.map(formatMod).join("
"); + } + + if (difference.extra.length > 0) { + dialogHtml += "

" + T.dialogs.modsDifference.newMods + "

"; + dialogHtml += difference.extra.map(formatMod).join("
"); + } + + const signals = this.dialogs.showWarning(T.dialogs.modsDifference.title, dialogHtml, [ + "cancel:good", + "continue:bad", + ]); + + return new Promise(resolve => { + signals.continue.add(resolve); + }); + } + /** * @param {SavegameMetadata} game */ @@ -754,6 +808,7 @@ export class MainMenuState extends GameState { savegame .readAsync() .then(() => this.app.adProvider.showVideoAd()) + .then(() => this.checkForModDifferences(savegame)) .then(() => { this.moveToState("InGameState", { savegame, diff --git a/src/js/states/mods.js b/src/js/states/mods.js index 42add8bd..1e0fe5f1 100644 --- a/src/js/states/mods.js +++ b/src/js/states/mods.js @@ -74,7 +74,7 @@ export class ModsState extends TextualGameState {
${mod.metadata.name} ${mod.metadata.description} - Website + ${T.mods.modWebsite}
${T.mods.version}${mod.metadata.version} ${T.mods.author}${mod.metadata.author} diff --git a/translations/base-en.yaml b/translations/base-en.yaml index 7070c318..0fa240be 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -423,6 +423,14 @@ dialogs: desc: >- Are you sure you want to delete ''? This can not be undone! + modsDifference: + title: Mod Warning + desc: >- + The currently installed mods differ from the mods the savegame was created with. + This might cause the savegame to break or not load at all. Are you sure you want to continue? + missingMods: Missing Mods + newMods: Newly installed Mods + ingame: # This is shown in the top left corner and displays useful keybindings in # every situation @@ -1096,6 +1104,7 @@ mods: author: Author version: Version + modWebsite: Website openFolder: Open Mods Folder folderOnlyStandalone: Opening the mod folder is only possible when running the standalone. browseMods: Browse Mods