diff --git a/css/general/layout.css b/css/general/layout.css index 877cf5d1015c1a6a5190e8868800b94b8ac93e54..a7e20161b5c234608c7c4a4aa7a973f93d88383c 100644 --- a/css/general/layout.css +++ b/css/general/layout.css @@ -43,6 +43,12 @@ div.grid-2columns-auto { grid-column-gap: 1em; } +div.grid-3columns-auto { + display: grid; + grid-template-columns: max-content auto auto; + grid-column-gap: 1em; +} + .margin-top { margin-top: 1em; } diff --git a/js/002-config/fc-js-init.js b/js/002-config/fc-js-init.js index 436a152428ab08ce4f8c53bd46b98903305565f7..44af7f7416466653c6bd764d03bd231d0bf63866 100644 --- a/js/002-config/fc-js-init.js +++ b/js/002-config/fc-js-init.js @@ -75,6 +75,8 @@ App.SF = {}; App.SecExp = {}; App.SlaveAssignment = {}; App.StartingGirls = {}; +App.Status = {}; +App.Status.storyReady = false; App.UI = {}; App.UI.Cheat = {}; App.UI.DOM = {}; diff --git a/js/dynamicJSLoading.js b/js/dynamicJSLoading.js index aafbcf33d65380d6fd4c63cacc48ee5105f7725c..3eb225f9d8c362df620aed8a4b0fcb209e760fa5 100644 --- a/js/dynamicJSLoading.js +++ b/js/dynamicJSLoading.js @@ -106,6 +106,236 @@ App.Loader = (function() { }, get lastScript() { return lastScript; + }, + get hasNextScript() { + return scriptQueue.length > 0; } }; })(); + + +/** + * Dedicated Modding API to abstract the loading mechanisms + */ +App.Modding = (function() { + let loadingDone = false; + /** + * @type {Mod} + */ + let currentMod = null; + + /** + * @type {string[]} + */ + let modsToLoad = []; + + /** + * @type {Mod[]} + */ + const loadedMods = []; + + class Mod { + /** + * @param {string} directory where this mod is located relative to 'mods/' + */ + constructor(directory) { + this._path = "./mods/" + directory; + this.name = directory; + this.version = "0"; + this.description = ""; + + currentMod = this; + loadedMods.push(this); + + App.Loader.loadGroup(this._path); + App.Loader.nextScript(); + } + + /** + * @returns {string} + */ + get path() { + return this._path; + } + + /** + * @param {string} path + */ + addSubscript(path) { + App.Loader.getGroup(this._path).queueSubscript(path); + } + } + + /** + * Loads the next subscript or mod + */ + function nextScript() { + if (App.Loader.hasNextScript) { + App.Loader.nextScript(); + } else if (modsToLoad.length > 0) { + new Mod(modsToLoad.shift()); + } else { + loadingDone = true; + } + } + + /** + * Load all mods + */ + function loadMods() { + modsToLoad = loadModList(); + nextScript(); + } + + /** + * @returns {string[]} modList + */ + function loadModList() { + return SugarCube.storage.get("modList") || []; + } + + return { + internal: { + load: loadMods, + /** @returns {string[]} modList */ + get modList() { + return loadModList(); + }, + /** @param {string[]} modList */ + set modList(modList) { + SugarCube.storage.set("modList", modList); + }, + get loadedMods() { + return loadedMods; + }, + get done() { + return loadingDone; + } + }, + scriptDone: nextScript, + get currentMod() { + return currentMod; + }, + }; +})(); + +App.UI.playerMods = function() { + let modList = loadModList(); + const container = document.createElement("div"); + container.append(makeModSettings()); + return container; + + function makeModSettings() { + const f = new DocumentFragment(); + App.UI.DOM.appendNewElement("h2", f, "Player mods"); + App.UI.DOM.appendNewElement("div", f, "Player mods are mods loaded from external files located at 'mods/'. If a mod does not exist loading will fail and never finish. Mods are save independent."); + if (!App.Modding.internal.done) { + f.append("Mods have not finished loading. ", App.UI.DOM.link("Refresh", () => { + modList = loadModList(); + refresh(); + })); + } + App.UI.DOM.appendNewElement("h3", f, "Currently loaded mods"); + f.append(loadedList()); + App.UI.DOM.appendNewElement("h3", f, "Edit mod list"); + f.append(makeEditor()); + return f; + } + + /** + * @returns {string[]} + */ + function loadModList() { + if (App.Status.storyReady) { + return App.Modding.internal.modList; + } else { + return []; + } + } + + function loadedList() { + const loadedMods = App.Modding.internal.loadedMods; + const div = document.createElement("div"); + div.classList.add("grid-3columns-auto"); + for (const mod of loadedMods) { + App.UI.DOM.appendNewElement("div", div, mod.name); + App.UI.DOM.appendNewElement("div", div, mod.version); + App.UI.DOM.appendNewElement("div", div, mod.description); + } + return div; + } + + function refresh() { + $(container).empty().append(makeModSettings()); + } + + function makeEditor() { + const div = document.createElement("div"); + div.append("Add new mod. Input the exact name of the directory for the mod at 'mods/': ", + App.UI.DOM.makeTextBox("", name => { + modList.push(name); + refresh(); + })); + + App.UI.DOM.appendNewElement("div", div, "Mods are loaded from top to bottom."); + + const listDiv = document.createElement("div"); + for (let i = 0; i < modList.length; i++) { + const row = document.createElement("div"); + row.append(up(i), " ", down(i), " ", remove(i), " ", modList[i]); + listDiv.append(row); + } + div.append(listDiv); + + div.append(App.UI.DOM.link("Finalize (Will reload the game!)", () => { + App.Modding.internal.modList = modList; + window.location.reload(); + })); + return div; + } + + /** + * @param {number} index + * @returns {HTMLElement} + */ + function up(index) { + if (index === 0) { + return App.UI.DOM.makeElement("span", "Up", ["gray"]); + } + const button = App.UI.DOM.makeElement("a", "Up"); + button.onclick = () => { + arraySwap(modList, index, index - 1); + refresh(); + }; + return button; + } + + /** + * @param {number} index + * @returns {HTMLElement} + */ + function down(index) { + if (index === modList.length - 1) { + return App.UI.DOM.makeElement("span", "Down", ["gray"]); + } + const button = App.UI.DOM.makeElement("a", "Down"); + button.onclick = () => { + arraySwap(modList, index, index + 1); + refresh(); + }; + return button; + } + + /** + * @param {number} index + * @returns {HTMLElement} + */ + function remove(index) { + const button = App.UI.DOM.makeElement("a", "Remove"); + button.onclick = () => { + modList.splice(index, 1); + refresh(); + }; + return button; + } +}; diff --git a/mods/example/index.js b/mods/example/index.js new file mode 100644 index 0000000000000000000000000000000000000000..67ff48ea01f56c20b7166fec46a0af68a0e403af --- /dev/null +++ b/mods/example/index.js @@ -0,0 +1,24 @@ +// Always scope your code to not pollute the global namespace. +{ + // Get the mod so we can modify it: + const mod = App.Modding.currentMod; + + // Set the name and version: + mod.name = "Example Mod"; + mod.version = "1.0"; + mod.description = "This is an example mod showing off the modding API and gives examples for moddable systems."; + + // For bigger mods it can be useful to split the mod in multiple parts. + // These parts can then be loaded as subscripts. + // Subscripts will be loaded in series and only once this script is done. + mod.addSubscript("subscript"); + + // Usage examples for moddable systems + mod.addSubscript("rulesAssistantGetters"); + + // Write to console, so we can easily see that the mod has loaded: + console.log("Index Loading: Success!"); + + // Tell the modding system this file is done. Do this at the end of EVERY script file. + App.Modding.scriptDone(); +} diff --git a/mods/example/rulesAssistantGetters.js b/mods/example/rulesAssistantGetters.js new file mode 100644 index 0000000000000000000000000000000000000000..e72951818c0e7f4e9ce221d324d7629905062467 --- /dev/null +++ b/mods/example/rulesAssistantGetters.js @@ -0,0 +1,18 @@ +// Add a new boolean getter for use in the RA condition editor. +// The first argument is a unique name for the getter. To ensure uniqueness with other mods it is recommended to +// prefix the name with the mod name. +// The second argument is an object describing the getter. +// 'name' is the string shown in the RA condition editor when selecting. +// 'description' should be a short description of the value the getter returns, with possible values if applicable. +// It will be displayed in the encyclopedia. +// 'val' is the actual getter reading out the value and returning it. It operates on a 'context' object with the current +// slave as an attribute. +// It is important that the getter always returns the data type that it is being added as, in this case 'string'. +// 'addNumber()' and 'addBoolean()' also exist. The usage is the same. +App.RA.Activation.getterManager.addString("example_slavename", + {name: "Slave Name", description: "The slave's name.", val: context => context.slave.slaveName} +); + +console.log("Rule Assistant Getters loaded."); + +App.Modding.scriptDone(); diff --git a/mods/example/subscript.js b/mods/example/subscript.js new file mode 100644 index 0000000000000000000000000000000000000000..98b9946a62ee900a8c75fbf2f40c967ef4593f96 --- /dev/null +++ b/mods/example/subscript.js @@ -0,0 +1,5 @@ +// Show the subscript loaded successfully: +console.log("Subscript loaded!"); + +// Tell the modding system this file is done. Do this at the end of EVERY script file. +App.Modding.scriptDone(); diff --git a/mods/mods.md b/mods/mods.md new file mode 100644 index 0000000000000000000000000000000000000000..e9eeb5db4b805293c088b72a73f9a1cfa205946d --- /dev/null +++ b/mods/mods.md @@ -0,0 +1,16 @@ +# Player Mods + +Player mods are scripts that are loaded after loading the game itself. + +There is currently very limited support for these mods with few systems exposing an API for modding. + +To use the mods in this directory copy the `mods` directory directly next to the game HTML file like this: + +* `FC_pregmod.html` +* `mods/` + * `example/` + * `index.js` + * ... + * ... + +The example mod explains the modding API itself and has examples for all moddable systems. diff --git a/src/gui/options/options.js b/src/gui/options/options.js index cdb48250c30b3fae8e60aef33bbf3ed55d13a747..ebda1302ef01f94bb3b9419ec40f57642bf046f9 100644 --- a/src/gui/options/options.js +++ b/src/gui/options/options.js @@ -778,6 +778,8 @@ App.UI.optionsPassage = function() { .addComment("This will enable a new way to interact with slaves. Currently working but missing flavor text."); el.append(options.render()); + + el.append(App.UI.playerMods()); return el; } }; diff --git a/src/js/eventHandlers.js b/src/js/eventHandlers.js index c72f2e3efddaaa583adc4962106404e95bf8b628..521bb80790a99932e8c9cc5a1df85733d39dc7fd 100644 --- a/src/js/eventHandlers.js +++ b/src/js/eventHandlers.js @@ -33,9 +33,12 @@ App.EventHandlers = function() { } function storyReady() { + App.Status.storyReady = true; App.UI.Theme.init(); App.UI.Hotkeys.init(); App.UI.GlobalTooltips.update(); + // Load player mods. Do this last + App.Modding.internal.load(); } function optionsChanged() {