1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-12-13 02:01:51 +00:00

Merge branch 'modloader' into processing-refactor

This commit is contained in:
Sense101 2022-02-01 13:59:28 +00:00 committed by GitHub
commit 745dcb3453
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 384 additions and 17 deletions

3
gulp/mod.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = function (source, map) {
return source + `\nexport let $s=(n,v)=>eval(n+"=v")`;
};

View File

@ -93,6 +93,9 @@ module.exports = ({ watch = false, standalone = false, chineseVersion = false, w
end: "typehints:end",
},
},
{
loader: path.resolve(__dirname, "mod.js"),
},
],
},
{

View File

@ -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 |

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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);
});
////////////////////////////////////////////////////////////////////
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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);
}
}

View File

@ -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");

View File

@ -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)) {

View File

@ -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()),
};

View File

@ -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);
},
});
}

View File

@ -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);

View File

@ -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.<number, typeof BaseSavegameInterface>} */
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");

View File

@ -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();
}
}

View File

@ -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<Entity>,
* beltPaths: Array<any>
* beltPaths: Array<any>,
* modExtraData: Object
* }} SerializedGame
*
* @typedef {{
@ -25,6 +34,7 @@
* dump: SerializedGame,
* stats: SavegameStats,
* lastUpdate: number,
* mods: SavegameStoredMods
* }} SavegameData
*
* @typedef {{

View File

@ -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 = {};
}
}
}

View File

@ -0,0 +1,5 @@
{
"type": "object",
"required": [],
"additionalProperties": true
}

View File

@ -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 `
<div class="dialogModsMod">
<div class="name">${mod.name}</div>
<div class="version">${T.mods.version} ${mod.version}</div>
<button class="website styledButton" onclick="window.open('${mod.website.replace(
/"'/,
""
)}')">${T.mods.modWebsite}
</button>
</div>
`;
}
if (difference.missing.length > 0) {
dialogHtml += "<h3>" + T.dialogs.modsDifference.missingMods + "</h3>";
dialogHtml += difference.missing.map(formatMod).join("<br>");
}
if (difference.extra.length > 0) {
dialogHtml += "<h3>" + T.dialogs.modsDifference.newMods + "</h3>";
dialogHtml += difference.extra.map(formatMod).join("<br>");
}
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,

View File

@ -74,7 +74,7 @@ export class ModsState extends TextualGameState {
<div class="mainInfo">
<span class="name">${mod.metadata.name}</span>
<span class="description">${mod.metadata.description}</span>
<a class="website" href="${mod.metadata.website}" target="_blank">Website</a>
<a class="website" href="${mod.metadata.website}" target="_blank">${T.mods.modWebsite}</a>
</div>
<span class="version"><strong>${T.mods.version}</strong>${mod.metadata.version}</span>
<span class="author"><strong>${T.mods.author}</strong>${mod.metadata.author}</span>

View File

@ -423,6 +423,14 @@ dialogs:
desc: >-
Are you sure you want to delete '<title>'? 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