diff --git a/src/js/core/background_resources_loader.js b/src/js/core/background_resources_loader.js index 316619c4..e1a585b6 100644 --- a/src/js/core/background_resources_loader.js +++ b/src/js/core/background_resources_loader.js @@ -9,6 +9,7 @@ import { SOUNDS, MUSIC } from "../platform/sound"; import { AtlasDefinition, atlasFiles } from "./atlas_definitions"; import { initBuildingCodesAfterResourcesLoaded } from "../game/meta_building_registry"; import { cachebust } from "./cachebust"; +import { MODS } from "../mods/modloader"; const logger = createLogger("background_loader"); @@ -232,6 +233,7 @@ export class BackgroundResourcesLoader { this.numAssetsToLoadTotal = 0; this.numAssetsLoaded = 0; }) + .then(MODS.hook_injectSprites.bind(MODS)) ); } } diff --git a/src/js/core/globals.js b/src/js/core/globals.js index 15197880..fcb9a3e8 100644 --- a/src/js/core/globals.js +++ b/src/js/core/globals.js @@ -2,6 +2,8 @@ import { Application } from "../application"; /* typehints:end */ +import { MODS } from "../mods/modloader"; + /** * Used for the bug reporter, and the click detector which both have no handles to this. * It would be nicer to have no globals, but this is the only one. I promise! @@ -14,4 +16,5 @@ export let GLOBAL_APP = null; export function setGlobalApp(app) { assert(!GLOBAL_APP, "Create application twice!"); GLOBAL_APP = app; + MODS.linkApp(app); } diff --git a/src/js/core/loader.js b/src/js/core/loader.js index cadbc048..b955beda 100644 --- a/src/js/core/loader.js +++ b/src/js/core/loader.js @@ -181,6 +181,10 @@ class LoaderImpl { w: sourceSize.w, h: sourceSize.h, }); + if (sprite.linksByResolution[scale]) { + // Seems data is already present, might have been replaced by a mod + continue; + } sprite.linksByResolution[scale] = link; } } diff --git a/src/js/game/theme.js b/src/js/game/theme.js index 251f4433..4f68997e 100644 --- a/src/js/game/theme.js +++ b/src/js/game/theme.js @@ -1,3 +1,5 @@ +import { MODS } from "../mods/modloader"; + export const THEMES = { dark: require("./themes/dark.json"), light: require("./themes/light.json"), @@ -7,4 +9,5 @@ export let THEME = THEMES.light; export function applyGameTheme(id) { THEME = THEMES[id]; + MODS.callHook("preprocessTheme", { id, theme: THEME }); } diff --git a/src/js/main.js b/src/js/main.js index 94f3d37a..3046820c 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -11,6 +11,7 @@ import { initItemRegistry } from "./game/item_registry"; import { initMetaBuildingRegistry } from "./game/meta_building_registry"; import { initGameModeRegistry } from "./game/game_mode_registry"; import { initGameSpeedRegistry } from "./game/game_speed_registry"; +import { MODS } from "./mods/modloader"; const logger = createLogger("main"); @@ -19,6 +20,8 @@ if (window.coreThreadLoadedCb) { window.coreThreadLoadedCb(); } +MODS.hook_init(); + // Logrocket // if (!G_IS_DEV && !G_IS_STANDALONE) { // const monthlyUsers = 300; // thousand diff --git a/src/js/mods/demo_mod.js b/src/js/mods/demo_mod.js new file mode 100644 index 00000000..c5003da0 --- /dev/null +++ b/src/js/mods/demo_mod.js @@ -0,0 +1,31 @@ +import { Mod } from "./mod"; + +export class DemoMod extends Mod { + constructor() { + super({ + authorContact: "tobias@tobspr.io", + authorName: "tobspr", + name: "Demo Mod", + version: "1", + id: "demo-mod", + }); + } + + hook_init() { + this.interface.registerCss(` + * { + color: red !important; + } + `); + + this.interface.registerSprite( + "sprites/colors/red.png", + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAB2AAAAdgB+lymcgAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAATkSURBVHic7ZpfiFRVHMc/v5m99+6qWYhCYKFGVJpPFYgK4UNQb1GwODO5M2OLUmASItGD6OJDYX8IIqPMdWdmdWZkE6GnnozqJWkxENMyg/75tP31z+7MnZ3760GrZf7fmXuvaPfzMvA7v3PO9/e7555z5pwLISEhISEhISH/U6SXyjo4aFaM+atUndUISwEUSmjkW5XouYH86I9eiJxJDC8Tra4EHhBxrGtW+UWQr43K1bMyMWF323ZXCSglko8JJFF5EljYwvUMytFqVcfmTeQuuuljejC5NBqVzQgx4MEWrpdAjjtIbqAwdsJNH+AyAXYi9TDKGwobXPYzI8J7hil7JZP5s5WjptN3VGzdrcpzwICbTgQ9gbLTLOa+6rxOh9iJ1FZV3gEMN6Jqevspok7SKIx/2qi4Ek+udZDDwD1d9wGziO6y8rl9nUlqg4LY8dQosLkHUXOpCmwxC9mxuUY7ln5WRQ8AUS86UWXUKma3CGgrv0i7hirx1Ot4FzxAVGG0lEhu+8dQiqdfUNGDeBQ8gAjDdiL1Wlu/VoXleHITyLhXompwVHkKQITjdPAwukOHrELucLPSpgnQeHxxGfOcwGJ/hAFw5frvAr86UJiysFdJofBro/KmWS+rudfn4OFa4L4FDyCwpCLmSIvyenRw6+12X/kiMN8vYQFzxZy17pKJA3/VFjQcAWWjNMStEzzAgnK0vKlRQcMEiMoT/uoJHhEeb2SvS4Beey3W+q4oeNbryEhdvHWGUiy9DFgUiKRgWVQ6//Pdtca6BESVJcHoCZ4os3WrWl0CnGj1Vnz6ADg47RNANdJy73xzI9VaS/2kEOW3YMQET6Qa/b3OVmsw+qPnAScQRcHiGH32hVpjXQLk0KHLwDeBSAqWs3LkyKVaY+ONEPKx/3oCRmgYU+M/Q1ot+CrmBiCO5hvZGybALI5PIpzyV1KAKF82OydsfgihvOKboKARaRpL0wSY9684fouMgknz/uUfNStseSRmx4YeUYmcxLfjKt9xJCLrzSOZL5o5tAzMLI5PqvK+97qCQZR3WwUPHTxZq192AKc9UxUcZ43q9EvtnNomQDKZEugQMOOJrCAQpnGcjTIx0VZzR++2VcidBoa4ObbICjJsHR0/04lzx5ObVcgeQ9nTva6AEN1l5TPFjt3dtm/H028qusNtvSAQYb+Zz25r7/kfrpc3o5DZqegHbuv5j2SN+1Zsd1vLdQIE1JqdeR4Ya+scHGPm7NVhGRlxPUd1/YWIgtiJ9B5Ub+i8IMLbRj77Yrtb4Kb1exVQiqW2i/AWwe8WFXjZKmTb3gC3oucEANiJVFKVg/Ty8YQ7KqIybBYzPd9ce5IAgMozqfWOw4fAnV612QiFKRXdOJDPfeJFe54lAK5/2NQXOQa6xst2/0U45dD3tFdfn4HH7+28idxF02IDvqwQesj8Y+E6L4MHj0fAXK7PC/vp/f7/sgg7zXz2gBe6avEtAQAzsfTyiGie7i9bJ1Uk0Z/PfOelrrn4unQNFDM/mLPTG1DZB9TdyrSgCrxq3mat8zN48HkEzMWODa1RiYwBK9u4fh9R2WwUM58HoSuwzYtZHD9pWvIQIrsVpmrLFaYQ2W1asjqo4CHAETAXHRwcKBv9j+JE7gUg4lywKqXPOjnACAkJCQkJCQkJ8Yi/AfA6e2lfA0oPAAAAAElFTkSuQmCC" + ); + } + + hook_preprocessTheme({ id, theme }) { + theme.map.background = "#eee"; + theme.items.outline = "#000"; + } +} diff --git a/src/js/mods/mod.js b/src/js/mods/mod.js new file mode 100644 index 00000000..7a0b3a7f --- /dev/null +++ b/src/js/mods/mod.js @@ -0,0 +1,50 @@ +/* typehints:start */ +import { ModInterface } from "./mod_interface"; +/* typehints:end */ + +export class Mod { + /** + * + * @param {object} metadata + * @param {string} metadata.name + * @param {string} metadata.version + * @param {string} metadata.authorName + * @param {string} metadata.authorContact + * @param {string} metadata.id + */ + constructor(metadata) { + this.metadata = metadata; + + /** + * @type {ModInterface} + */ + this.interface = undefined; + } + + hook_init() {} + + executeGuarded(taskName, task) { + try { + return task(); + } catch (ex) { + console.error(ex); + alert( + "Mod " + + this.metadata.name + + " (version " + + this.metadata.version + + ")" + + " failed to execute '" + + taskName + + "':\n\n" + + ex + + "\n\nPlease forward this to the mod author:\n\n" + + this.metadata.authorName + + " (" + + this.metadata.authorContact + + ")" + ); + throw ex; + } + } +} diff --git a/src/js/mods/mod_interface.js b/src/js/mods/mod_interface.js new file mode 100644 index 00000000..47bff484 --- /dev/null +++ b/src/js/mods/mod_interface.js @@ -0,0 +1,77 @@ +/* typehints:start */ +import { Application } from "../application"; +import { ModLoader } from "./modloader"; +/* typehints:end */ + +import { createLogger } from "../core/logging"; +import { AtlasSprite, SpriteAtlasLink } from "../core/sprites"; +import { Mod } from "./mod"; + +const LOG = createLogger("mod-interface"); + +export class ModInterface { + /** + * + * @param {ModLoader} modLoader + * @param {Mod} mod + */ + constructor(modLoader, mod) { + /** + * @param {Application} app + */ + this.app = undefined; + + this.modLoader = modLoader; + this.mod = mod; + } + + registerCss(cssString) { + const element = document.createElement("style"); + element.textContent = cssString; + element.setAttribute("data-mod-id", this.mod.metadata.id); + element.setAttribute("data-mod-name", this.mod.metadata.name); + document.head.appendChild(element); + } + + registerSprite(spriteId, base64string) { + assert(base64string.startsWith("data:image")); + const img = new Image(); + img.src = base64string; + + const sprite = new AtlasSprite(spriteId); + + const link = new SpriteAtlasLink({ + w: img.width, + h: img.height, + atlas: img, + packOffsetX: 0, + packOffsetY: 0, + packedW: img.width, + packedH: img.height, + packedX: 0, + packedY: 0, + }); + + sprite.linksByResolution["0.25"] = link; + sprite.linksByResolution["0.5"] = link; + sprite.linksByResolution["0.75"] = link; + + // @ts-ignore + sprite.modSource = this.mod; + + const oldSprite = this.modLoader.lazySprites.get(spriteId); + if (oldSprite) { + LOG.error( + "Sprite '" + + spriteId + + "' is provided twice, once by mod '" + + // @ts-ignore + oldSprite.modSource.metadata.name + + "' and once by mod '" + + this.mod.metadata.name + + "'. This could cause artifacts." + ); + } + this.modLoader.lazySprites.set(spriteId, sprite); + } +} diff --git a/src/js/mods/modloader.js b/src/js/mods/modloader.js new file mode 100644 index 00000000..2b6cbbaf --- /dev/null +++ b/src/js/mods/modloader.js @@ -0,0 +1,77 @@ +import { Loader } from "../core/loader"; +import { createLogger } from "../core/logging"; +import { AtlasSprite } from "../core/sprites"; +import { DemoMod } from "./demo_mod"; +import { Mod } from "./mod"; +import { ModInterface } from "./mod_interface"; + +const LOG = createLogger("mods"); + +export class ModLoader { + constructor() { + LOG.log("modloader created"); + + /** @type {Mod[]} */ + this.mods = []; + + /** @type {Map} */ + this.lazySprites = new Map(); + + this.initialized = false; + } + + linkApp(app) { + this.app = app; + this.mods.forEach(mod => (mod.interface.app = app)); + } + + hook_init() { + LOG.log("hook:init"); + this.initialized = true; + this.mods.forEach(mod => { + LOG.log("Loading mod", mod.metadata.name); + mod.interface = new ModInterface(this, mod); + mod.executeGuarded("hook_init", mod.hook_init.bind(mod)); + }); + } + + hook_injectSprites() { + LOG.log("hook:injectSprites"); + this.lazySprites.forEach((sprite, key) => { + Loader.sprites.set(key, sprite); + console.log("override", key); + }); + } + + callHook(id, structuredArgs) { + LOG.log("hook:" + id); + this.mods.forEach(mod => { + const handler = mod["hook_" + id]; + if (handler) { + mod.executeGuarded("hook:" + id, handler.bind(mod, structuredArgs)); + } + }); + } + + registerSprite() {} + + registerGameState() {} + + registerBuilding() {} + + /** + * + * @param {Mod} mod + */ + registerMod(mod) { + LOG.log("Registering mod", mod.metadata.name); + if (this.initialized) { + throw new Error("Mods are already initialized, can not add mod afterwards."); + } + this.mods.push(mod); + } +} + +export const MODS = new ModLoader(); + +MODS.registerMod(new DemoMod());