1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-12-13 10:11:50 +00:00

Begin mod support

This commit is contained in:
Jasper Meggitt 2020-05-31 04:23:38 -07:00
parent 72feaa89e1
commit ebebb0a188
5 changed files with 738 additions and 0 deletions

View File

@ -36,6 +36,8 @@ import { MainMenuState } from "./states/main_menu";
import { MobileWarningState } from "./states/mobile_warning";
import { PreloadState } from "./states/preload";
import { SettingsState } from "./states/settings";
import { ModsState } from "./states/mods";
import { ModManager } from "./core/mod_manager";
const logger = createLogger("application");
@ -70,6 +72,7 @@ export class Application {
this.savegameMgr = new SavegameManager(this);
this.inputMgr = new InputDistributor(this);
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
this.modManager = new ModManager(this);
// Platform dependent stuff
@ -161,6 +164,7 @@ export class Application {
KeybindingsState,
AboutState,
ChangelogState,
ModsState,
];
for (let i = 0; i < states.length; ++i) {

63
src/js/core/mod_api.js Normal file
View File

@ -0,0 +1,63 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
import { globalLog, globalError } from "./logging";
import { GameRoot } from "../game/root";
export class ModApi {
/**
*
* @param {Application} app
*/
constructor(app) {
this._loadedMods = [];
this._activeModInstances = [];
this.app = app;
}
/**
* Registers a new mod function
* @param {function} mod
*/
registerModImplementation(mod) {
globalLog(this, "🧪 Registering new mod");
this._loadedMods.push(mod);
}
/**
* Loads all mods
* @param {GameRoot} root
*/
_instantiateMods(root) {
if (this._loadedMods.length > 0) {
globalLog(this, "🧪 Instantiating", this._loadedMods.length, "mods");
for (let i = 0; i < this._loadedMods.length; ++i) {
const mod = this._loadedMods[i];
try {
mod(root);
} catch (err) {
globalError(this, "🧪 Failed to initialize mod:", err);
}
}
}
root.signals.aboutToDestruct.add(() => {
for (let i = 0; i < this._modClickDetectors.length; ++i) {
this._modClickDetectors[i].cleanup();
}
this._modClickDetectors = [];
});
}
/**
* Injects css into the page
* @param {string} css
*/
injectCss(css) {
const styleElement = document.createElement("style");
styleElement.textContent = css;
styleElement.type = "text/css";
styleElement.media = "all";
document.head.appendChild(styleElement);
}
}

334
src/js/core/mod_manager.js Normal file
View File

@ -0,0 +1,334 @@
import { ReadWriteProxy } from "./read_write_proxy";
import { ExplainedResult } from "./explained_result";
import { globalError, globalLog, globalWarn } from "./logging";
import { ModApi } from "./mod_api";
import { queryParamOptions } from "./query_parameters";
// When changing this, make sure to also migrate savegames since they store the installed mods!
/**
* @typedef {{
* id: string,
* name: string,
* author: string,
* website: string,
* description: string,
* url: string,
* version: string,
* is_game_changing: boolean
* }} ModData
*/
export class ModManager extends ReadWriteProxy {
constructor(app) {
super(app, "mods.bin");
this.modApi = new ModApi(app);
// Whether the mod manager needs a restart to reload all mods
this.needsRestart = false;
// The promise for the next mod
this.nextModResolver = null;
this.nextModRejector = null;
}
/////// BEGIN RW PROXY //////
verify(data) {
// Todo
return ExplainedResult.good();
}
getDefaultData() {
return {
version: this.getCurrentVersion(),
mods: [],
};
}
getCurrentVersion() {
return 1002;
}
migrate(data) {
// Simply reset
if (data.version < 1002) {
data.mods = [];
data.version = 1002;
}
return ExplainedResult.good();
}
initialize() {
// Read and directly write latest data back
return this.readAsync()
.then(() => this.loadMods())
.then(() => this.writeAsync());
}
save() {
return this.writeAsync();
}
/////// END RW PROXY //////
/**
* Retursn whether there are any mods enabled
*/
getHasModsEnabled() {
return (
this.getNumMods() > 0 ||
this.modApi._loadedMods.length > 0 ||
this.modApi._activeModInstances.length > 0
);
}
/**
* Retursn whether there are any mods enabled which change the game
*/
getHasGameChangingModsInstalled() {
return this.getMods().find(mod => mod.is_game_changing) != null;
}
/**
* Returns whether a restart is required to apply all mods
*/
getNeedsRestart() {
return this.needsRestart;
}
/**
* Checks if the given mods of the savegame differ from the
* installed mods
* @param {Array<ModData>} savegameMods
*/
checkModsNeedSync(savegameMods) {
if (this.needsRestart) {
return true;
}
const ourString = this.getMods()
.filter(m => m.is_game_changing)
.map(m => m.url)
.join("@@@");
const savegameString = savegameMods
.filter(m => m.is_game_changing)
.map(m => m.url)
.join("@@@");
if (ourString === savegameString) {
return false;
}
return true;
}
/**
* Attempts to register a mod after it was loaded
* @param {function} modCallback
*/
attemptRegisterMod(modCallback) {
assert(this.nextModResolver, "Got mod registration while mod promise was not expected");
assert(this.nextModRejector, "Got mod registration while mod promise was not expected");
try {
modCallback(this.modApi);
} catch (ex) {
console.error("Mod failed to load:", ex);
const rejector = this.nextModRejector;
this.nextModResolver = null;
this.nextModRejector = null;
rejector(ex);
return;
}
const resolver = this.nextModResolver;
this.nextModResolver = null;
this.nextModRejector = null;
resolver();
}
/**
* Attempts to load all mods
*/
loadMods() {
window.registerMod = mod => this.attemptRegisterMod(mod);
// Load all mods
let promise = Promise.resolve(null);
const mods = this.getMods();
for (let i = 0; i < mods.length; ++i) {
const mod = mods[i];
promise = promise.then(() => {
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(reject, 60 * 1000);
}),
fetch(mod.url, {
method: "GET",
cache: "no-cache",
}),
])
.then(res => res.text())
.catch(err => {
globalError(this, "Failed to load mod", mod.name, ":", err);
return Promise.reject(
"Downloading '" + mod.name + "' from '" + mod.url + "' timed out"
);
})
.then(modCode => {
return Promise.race([
new Promise((resolve, reject) => {
setTimeout(reject, 60 * 1000);
}),
new Promise((resolve, reject) => {
this.nextModResolver = resolve;
this.nextModRejector = reject;
// Make sure we don't get errors from mods
window.anyModLoaded = true;
const modScript = document.createElement("script");
modScript.textContent = modCode;
modScript.type = "text/javascript";
modScript.setAttribute("data-mod-name", mod.name);
modScript.setAttribute("data-mod-version", mod.version);
try {
document.head.appendChild(modScript);
} catch (ex) {
console.error("Failed to insert mod, bad js:", ex);
this.nextModResolver = null;
this.nextModRejector = null;
reject("Mod is invalid");
}
}),
]);
})
.catch(err => {
globalError(this, "Failed to initializing mod", mod.name, ":", err);
return Promise.reject("Initializing '" + mod.name + "' failed: " + err);
});
});
}
promise = promise.catch(err => {
this.needsRestart = true;
throw err;
});
return promise;
}
/**
* Returns all installed mods
* @returns {Array<ModData>}
*/
getMods() {
if (queryParamOptions.modDeveloper) {
return [
{
name: "Local Testing Mod",
author: "nobody",
website: "http://example.com",
description:
"This will load the mod from localhost:8000. Make sure to read the modding docs!",
url: "http://localhost:8000/mod.js",
version: "1.0.0",
is_game_changing: false,
id: "local_dev",
},
];
}
return this.currentData.mods;
}
/**
* Returns the total number of mods
* @returns {number}
*/
getNumMods() {
return this.getMods().length;
}
/**
* Installs a new mod
* @param {ModData} mod
* @param {string} id
* @returns {Promise<void>}
*/
installMod(mod, id) {
if (queryParamOptions.modDeveloper) {
return Promise.reject("Can not install mods in developer mode");
}
// Check if a mod with the same name is already installed
const mods = this.getMods();
for (let i = 0; i < mods.length; ++i) {
if (mods[i].name === mod.name) {
return Promise.reject("A mod with the same name is already installed");
}
}
this.currentData.mods.push(mod);
// TODO: Check if this use of trackUiClick is okay.
// Track download in the background
return this.writeAsync()
.then(() => this.app.analytics.trackUiClick("mod/" + id))
.then(() => (this.needsRestart = true))
.then(() => null);
}
/**
* Attempts to synchronize the mods from the savegame
* @param {Array<ModData>} savegameMods
* @returns {Promise<void>}
*/
syncModsFromSavegame(savegameMods) {
// First, remove all game changing mods from ours
let newMods = this.getMods().filter(mod => !mod.is_game_changing);
// Now, push all game changing mods from the savegame
const gamechangingMods = savegameMods.filter(mod => mod.is_game_changing);
for (let i = 0; i < gamechangingMods.length; ++i) {
newMods.push(gamechangingMods[i]);
}
this.currentData.mods = newMods;
return this.writeAsync()
.then(() => (this.needsRestart = true))
.then(() => null);
}
/**
* Finds a mod by its name
* @param {string} name
* @returns {ModData}
*/
getModByName(name) {
return this.getMods().find(m => m.name === name);
}
/**
* Removes a mod
* @returns {Promise<void>}
*/
uninstallMod(name) {
if (queryParamOptions.modDeveloper) {
return Promise.reject("Can not uninstall mods in developer mode");
}
const mods = this.getMods();
for (let i = 0; i < mods.length; ++i) {
if (mods[i].name === name) {
mods.splice(i, 1);
return this.writeAsync()
.then(() => (this.needsRestart = true))
.then(() => null);
}
}
return Promise.reject("Mod not found in installed mods");
}
}

294
src/js/states/mods.js Normal file
View File

@ -0,0 +1,294 @@
import { TextualGameState } from "../core/textual_game_state";
import { T } from "../translations";
import { removeAllChildren } from "../core/utils";
import { globalWarn } from "../core/logging";
/**
* @typedef {{
* id: string,
* name: string,
* website: string,
* description: string,
* url: string,
* author: string,
* version: string,
* install_count: string,
* is_game_changing: boolean
* }} APIModData
*/
export class ModsState extends TextualGameState {
constructor() {
super("ModsState");
}
getStateHeaderTitle() {
return T.mods.title;
}
getMainContentHTML() {
return `
<span class="devHint">
${T.mods.dev_hint.replace(
"<modding_docs>",
`<span class='moddingDocsLink'>${T.mods.modding_docs}</span>`
)}
</span>
<strong class="category_label">${T.mods.installed_mods}</strong>
<div class="installedMods"></div>
<strong class="category_label">${T.mods.mods_browser}</strong>
<div class="modGallery"></div>
`;
}
onEnter() {
this.installedModsElement = this.htmlElement.querySelector(".installedMods");
this.modGalleryElement = this.htmlElement.querySelector(".modGallery");
this.rerenderInstalledMods();
this.refreshModGallery();
this.trackClicks(this.htmlElement.querySelector(".moddingDocsLink"), this.openModdingDocs);
}
openModdingDocs() {
this.app.platformWrapper.openExternalLink("https://github.com/tobspr/yorg.io-3-modding-docs");
}
rerenderInstalledMods() {
// TODO: We are leaking click detectors here
removeAllChildren(this.installedModsElement);
const mods = this.app.modManager.getMods();
if (mods.length === 0) {
this.installedModsElement.innerHTML = T.mods.no_mods_found;
return;
}
const frag = document.createDocumentFragment();
for (let i = 0; i < mods.length; ++i) {
const mod = mods[i];
const elem = this.makeModElement(mod);
frag.appendChild(elem);
const uninstallButton = document.createElement("button");
uninstallButton.classList.add("styledButton", "uninstallMod");
uninstallButton.innerText = T.mods.uninstall_mod;
elem.appendChild(uninstallButton);
this.trackClicks(uninstallButton, () => this.uninstallMod(mod));
}
this.installedModsElement.appendChild(frag);
}
refreshModGallery() {
// TODO: We are leaking click detectors here
removeAllChildren(this.modGalleryElement);
this.modGalleryElement.innerHTML = `<span class="prefab_LoadingTextWithAnim">${T.global.loading}</span>`;
this.app.api
.fetchModGallery()
.then(mods => this.rerenderModsGallery(mods))
.catch(err => {
globalWarn(this, "Failed to fetch mod gallery:", err);
this.modGalleryElement.innerHTML = T.mods.mod_gallery_fail + " " + err;
});
}
/**
*
* @param {APIModData|import("../core/mod_manager").ModData} mod
*/
makeModElement(mod) {
const elem = document.createElement("div");
elem.classList.add("mod", "cardbox");
const title = document.createElement("span");
title.classList.add("title");
title.innerText = mod.name;
elem.appendChild(title);
const version = document.createElement("span");
version.classList.add("version");
version.innerText = mod.version;
title.appendChild(version);
const author = document.createElement("span");
author.classList.add("author");
author.innerText = mod.author;
elem.appendChild(author);
const website = document.createElement("span");
website.classList.add("website");
website.innerText = T.mods.website;
elem.appendChild(website);
this.trackClicks(website, () => this.app.platformWrapper.openExternalLink(mod.website));
const description = document.createElement("span");
description.classList.add("description");
description.innerText = mod.description;
elem.appendChild(description);
if (mod.is_game_changing) {
const hint = document.createElement("span");
hint.classList.add("gameChangingHint");
hint.innerText = T.mods.gamechanging_hint;
elem.appendChild(hint);
}
return elem;
}
/**
*
* @param {Array<APIModData>} mods
*/
rerenderModsGallery(mods) {
// TODO: We are leaking click detectors here
removeAllChildren(this.modGalleryElement);
if (mods.length === 0) {
this.modGalleryElement.innerHTML = T.mods.no_mods_found;
return;
}
const frag = document.createDocumentFragment();
for (let i = 0; i < mods.length; ++i) {
const mod = mods[i];
const elem = this.makeModElement(mod);
frag.appendChild(elem);
const installCount = document.createElement("span");
installCount.classList.add("installCount");
installCount.innerText = T.mods.install_count.replace("<installs>", "" + mod.install_count);
elem.appendChild(installCount);
if (this.app.modManager.getModByName(mod.name)) {
const installedText = document.createElement("span");
installedText.innerText = T.mods.mod_installed;
installedText.classList.add("installedText");
elem.appendChild(installedText);
elem.classList.add("installed");
} else {
const installButton = document.createElement("button");
installButton.classList.add("styledButton", "installMod");
installButton.innerText = T.mods.install_mod;
elem.appendChild(installButton);
this.trackClicks(installButton, () => this.tryInstallMod(mod));
}
}
this.modGalleryElement.appendChild(frag);
}
/**
*
* @param {import("../core/mod_manager").ModData} mod
*/
uninstallMod(mod) {
const closeLoading = this.dialogs.showLoadingDialog();
this.app.modManager.uninstallMod(mod.name).then(
() => {
closeLoading();
const { restart } = this.dialogs.showInfo(
T.mods.mod_uninstalled_dialog.title,
T.mods.mod_uninstalled_dialog.desc,
this.app.platformWrapper.getSupportsRestart() ? ["ok:good", "restart:misc"] : ["ok:good"]
);
if (restart) {
restart.add(() => this.app.platformWrapper.performRestart());
}
this.refreshModGallery();
this.rerenderInstalledMods();
},
err => {
closeLoading();
this.dialogs.showWarning(T.global.error, err);
}
);
}
/**
*
* @param {APIModData} mod
*/
tryInstallMod(mod) {
const { install } = this.dialogs.showWarning(
T.mods.mod_warning.title,
`
${T.mods.mod_warning.desc}
<ul>
<li>${T.mods.mod_warning.point_0}</li>
<li>${T.mods.mod_warning.point_1}</li>
<li>${T.mods.mod_warning.point_2}</li>
<li>${T.mods.mod_warning.point_3}</li>
${mod.is_game_changing ? `<li>${T.mods.mod_warning.disclaimer_gamechanging}</li>` : ""}
</ul>
`,
// @ts-ignore
["cancel:good", window.modsInstallWarningShown ? "install:bad" : "install:bad:timeout"]
);
install.add(() => this.doInstallMod(mod));
// @ts-ignore
window.modsInstallWarningShown = true;
}
/**
*
* @param {APIModData} mod
*/
doInstallMod(mod) {
const closeLoading = this.dialogs.showLoadingDialog();
this.app.modManager
.installMod(
{
name: mod.name,
author: mod.author,
website: mod.website,
url: mod.url,
version: mod.version,
description: mod.description,
is_game_changing: mod.is_game_changing,
id: mod.id,
},
mod.id
)
.then(
() => {
closeLoading();
const { restart } = this.dialogs.showInfo(
T.mods.mod_installed_dialog.title,
T.mods.mod_installed_dialog.desc,
this.app.platformWrapper.getSupportsRestart()
? ["ok:good", "restart:misc"]
: ["ok:good"]
);
if (restart) {
restart.add(() => this.app.platformWrapper.performRestart());
}
this.refreshModGallery();
this.rerenderInstalledMods();
},
err => {
closeLoading();
this.dialogs.showWarning(T.global.error, err);
}
);
}
getDefaultPreviousState() {
return "SettingsState";
}
}

View File

@ -55,6 +55,49 @@ global:
shift: SHIFT
space: SPACE
mods:
title: Mods
# Links to developer information
dev_hint: Interested in creating mods? Check out <modding_docs>!
modding_docs: Mod Documentation
installed_mods: Installed Mods
install_count: <installs> mods installed
mods_browser: Popular Mods
website: Visit Website
no_mods_found: No mods found
mod_gallery_fail: >-
Failed to fetch mod gallery:
install_mod: Install
uninstall_mod: Remove
mod_installed: Installed
gamechanging_hint: This mod is gamechanging and thus cannot be removed once added to a game save!
mod_warning:
title: Modded Warning
desc: >-
Please note the following when installing mods:
point_0: Mods are not created by the developer.
point_1: They may crash and corrupt your games.
point_2: Mods are not created by the developer, and the developer (tobspr) is not responsible for any damage caused by mods.
point_3: Mods have full access to the game, including other game save files.
disclaimer_gamechanging: Gamechanging mods cannot be removed once added to a game save.
mod_installed_dialog:
title: Mod Installed
desc: The mod has succssfully been installed and will be loaded after a restart.
mod_uninstalled_dialog:
title: Mod Removed
desc: The mod has successfully beeen removed and will get unloaded after a restart.
demoBanners:
# This is the "advertisement" shown in the main menu and other various places
title: Hey!