1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-06-13 13:04:03 +00:00

Add receiver flexibility and more achievements

- Add check to see if necessary to create achievement and add receiver
- Add remove receiver functionality when achievement is unlocked
This commit is contained in:
Greg Considine 2021-03-03 11:59:56 -05:00
parent 9597f3e89c
commit 835317477d
8 changed files with 291 additions and 154 deletions

View File

@ -16,10 +16,12 @@ export class AchievementProxy {
return;
}
this.provider.initialize()
.then(() => {
this.root.signals.achievementUnlocked.add(this.provider.unlock, this.provider);
this.root.signals.postLoadHook.add(this.onLoad, this);
}
onLoad() {
this.provider.initialize(this.root)
.then(() => {
logger.log("Listening for unlocked achievements");
})
.catch(err => {

View File

@ -164,9 +164,7 @@ export class Blueprint {
anyPlaced = true;
}
if (anyPlaced) {
root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.blueprints);
}
root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.blueprints, anyPlaced);
return anyPlaced;
});

View File

@ -1,7 +1,6 @@
import { globalConfig } from "../core/config";
import { RandomNumberGenerator } from "../core/rng";
import { clamp } from "../core/utils";
import { ACHIEVEMENTS } from "../platform/achievement_provider";
import { BasicSerializableObject, types } from "../savegame/serialization";
import { enumColors } from "./colors";
import { enumItemProcessorTypes } from "./components/item_processor";
@ -261,12 +260,6 @@ export class HubGoals extends BasicSerializableObject {
this.computeNextGoal();
this.root.signals.storyGoalCompleted.dispatch(this.level - 1, reward);
if (this.level - 1 === 20) {
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.wires);
} else if (this.level - 1 === 26) {
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.freedom);
}
}
/**

View File

@ -253,6 +253,7 @@ export class ShapeDefinitionManager extends BasicSerializableObject {
this.shapeKeyToDefinition[id] = definition;
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.theLogo, definition);
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.toTheMoon, definition);
// logger.log("Registered shape with key (2)", id);
return definition;

View File

@ -276,9 +276,7 @@ export class ItemEjectorSystem extends GameSystemWithFilter {
if (storageComp.canAcceptItem(item)) {
storageComp.takeItem(item);
if (storageComp.storedCount === 1) {
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.storage);
}
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.storage, storageComp);
return true;
}

View File

@ -698,11 +698,7 @@ export class WireSystem extends GameSystemWithFilter {
return;
}
if (entity.components.Wire && entity.registered &&
this.root.entityMgr.componentToEntity.Wire.length === 100) {
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.networked);
}
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.networked, entity);
// Invalidate affected area
const originalRect = staticComp.getTileSpaceBounds();

View File

@ -1,5 +1,9 @@
/* typehints:start */
import { Application } from "../application";
import { StorageComponent } from "../game/components/storage";
import { Entity } from "../game/entity";
import { GameRoot } from "../game/root";
import { ShapeDefinition } from "../game/shape_definition";
/* typehints:end */
export const ACHIEVEMENTS = {
@ -13,8 +17,21 @@ export const ACHIEVEMENTS = {
freedom: "freedom",
networked: "networked",
theLogo: "theLogo",
toTheMoon: "toTheMoon",
millionBlueprintShapes: "millionBlueprintShapes",
hundredShapes: "hundredShapes",
};
const ONE_HUNDRED = 100;
const ONE_MILLION = 1000000;
const BLUEPRINT_SHAPE = "CbCbCbRb:CwCwCwCw";
const LOGO_SHAPE = "RuCw--Cw:----Ru--";
const ROCKET_SHAPE = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
const FREEDOM_LEVEL = 26;
const WIRES_LEVEL = 20;
const NETWORKED_WIRE_COUNT = 100;
export class AchievementProviderInterface {
/** @param {Application} app */
constructor(app) {
@ -30,16 +47,6 @@ export class AchievementProviderInterface {
return Promise.reject();
}
/**
* Call to unlock an achievement
* @param {string} [key] - A property within the ACHIEVEMENTS enum or empty if
* bypassing.
* @returns {void}
*/
unlock(key) {
abstract;
}
/**
* Checks if achievements are supported in the current build
* @returns {boolean}
@ -51,13 +58,26 @@ export class AchievementProviderInterface {
}
export class Achievement {
/**
* @param {string} key - An ACHIEVEMENTS key
*/
constructor (key) {
/** @param {string} key - An ACHIEVEMENTS key */
constructor(key) {
this.key = key;
this.unlock = null;
this.isValid = null;
this.unlocked = false;
this.signal = null;
this.receiver = null;
this.activate = null;
this.activatePromise = null;
}
isValid () {
return true;
}
unlock() {
if (!this.activatePromise) {
this.activatePromise = this.activate(this.key);
}
return this.activatePromise;
}
}
@ -66,73 +86,221 @@ export class AchievementCollection {
* @param {string[]} keys - An array of ACHIEVEMENTS keys
* @param {function} [activate] - Resolves when provider activation is complete
*/
constructor (keys, activate) {
constructor(keys, activate) {
this.map = new Map();
this.activate = activate ? activate : () => Promise.resolve();
this.activate = activate;
assert(Object.keys(ACHIEVEMENTS).length === keys.length, "Mismatched achievements");
for (var i = 0; i < keys.length; i++) {
assert(ACHIEVEMENTS[keys[i]], "Achievement does not exist: " + keys[i]);
}
}
const achievement = new Achievement(keys[i]);
this.setValidation(achievement);
this.map.set(keys[i], achievement);
/** @param {GameRoot} root */
initialize(root) {
this.root = root;
this.root.signals.achievementUnlocked.add(this.unlock, this);
this.createAndSet(ACHIEVEMENTS.painting)
this.createAndSet(ACHIEVEMENTS.cutting)
this.createAndSet(ACHIEVEMENTS.rotating)
this.createAndSet(ACHIEVEMENTS.stacking)
this.createAndSet(ACHIEVEMENTS.blueprints, this.isBlueprintsValid);
if (this.isWiresRelevant()) {
this.createAndSet(ACHIEVEMENTS.wires, this.isWiresValid, "storyGoalCompleted");
}
this.createAndSet(ACHIEVEMENTS.storage, this.isStorageValid, "entityGotNewComponent");
if (this.isFreedomRelevant()) { // ...is it?
this.createAndSet(ACHIEVEMENTS.freedom, this.isFreedomValid, "storyGoalCompleted");
}
this.createAndSet(ACHIEVEMENTS.networked, this.isNetworkedValid);
this.createAndSet(ACHIEVEMENTS.theLogo, this.isTheLogoValid);
this.createAndSet(ACHIEVEMENTS.toTheMoon, this.isToTheMoonValid);
this.createAndSet(
ACHIEVEMENTS.millionBlueprintShapes,
this.isMillionBlueprintShapesValid,
"shapeDelivered"
);
if (this.isHundredShapesRelevant()) {
this.createAndSet(
ACHIEVEMENTS.hundredShapes,
this.isHundredShapesValid,
"shapeDelivered"
);
}
}
/**
* @param {string} key - Maps to an Achievement
* @returns {boolean}
* @param {function} [isValid] - Validates achievement when a signal message is received
* @param {string} [signal] - Signal name to listen to for unlock attempts
*/
has(key) {
return this.map.has(key);
createAndSet(key, isValid, signal) {
const achievement = new Achievement(key);
achievement.activate = this.activate;
if (isValid) {
achievement.isValid = isValid.bind(this);
}
if (signal) {
achievement.signal = signal;
achievement.receiver = this.unlock.bind(this, key);
this.root.signals[achievement.signal].add(achievement.receiver);
}
this.map.set(key, achievement);
}
/**
* @param {string} key - Maps to an Achievement
* @param {*} [details] - Additional information as needed to validate
* @returns {boolean}
* @param {*[]} [arguments] - Additional arguments received from signal dispatches
*/
isValid(key, details) {
return this.map.get(key).isValid(details);
}
unlock(key) {
if (!this.map.has(key)) {
console.log("Achievement unlocked or irrelevant:", key);
return;
}
/**
* @param {string} key - Maps to an Achievement
* @returns {Promise<void>}
*/
unlock(key) {
const achievement = this.map.get(key);
return achievement.unlock = achievement.unlock || this.activate(achievement)
.then(() => {
this.map.delete(key);
})
.catch(err => {
achievement.unlock = null;
throw err;
})
}
/**
* @param {Achievement} achievement - Achievement receiving a validation function
*/
setValidation(achievement) {
switch (achievement.key) {
case ACHIEVEMENTS.theLogo:
achievement.isValid = this.isTheLogoValid;
break;
default:
achievement.isValid = () => true;
break;
if (!achievement.isValid(...arguments)) {
console.log("Achievement is invalid:", key);
return;
}
return achievement.unlock()
.finally(() => {
if (achievement.receiver) {
this.root.signals[achievement.signal].remove(achievement.receiver);
console.log("Achievement receiver removed:", key);
}
this.map.delete(key);
if (!this.hasDefaultReceivers()) {
this.root.signals.achievementUnlocked.remove(this.unlock);
console.log("removed achievementUnlocked receiver");
}
});
}
hasDefaultReceivers() {
if (!this.map.size) {
return false;
}
for(let achievement of this.map.values()) {
if (!achievement.signal) {
return true;
}
}
return false;
}
/**
* @param {string} shortKey - The shape's shortKey to check
* @param {string} key
* @param {boolean} anyPlaced
* @returns {boolean}
*/
isTheLogoValid(shortKey) {
return shortKey === "RuCw--Cw:----Ru--";
isBlueprintsValid(key, anyPlaced) {
return anyPlaced;
}
/** @returns {boolean} */
isWiresRelevant() {
return this.root.hubGoals.level < WIRES_LEVEL;
}
/**
* @param {string} key
* @param {number} level
* @returns {boolean}
*/
isWiresValid(key, level) {
return level === WIRES_LEVEL;
}
/**
* @param {string} key
* @param {StorageComponent} storage
* @returns {boolean}
*/
isStorageValid(key, storage) {
return storage.storedCount >= 1;
}
/** @returns {boolean} */
isFreedomRelevant() {
return this.root.hubGoals.level < FREEDOM_LEVEL;
}
/**
* @param {string} key
* @param {number} level
* @returns {boolean}
*/
isFreedomValid(key, level) {
return level === FREEDOM_LEVEL;
}
/**
* @param {string} key
* @param {Entity} entity
* @returns {boolean}
*/
isNetworkedValid(key, entity) {
return entity.components.Wire &&
entity.registered &&
entity.root.entityMgr.componentToEntity.Wire.length === NETWORKED_WIRE_COUNT;
}
/**
* @param {string} key
* @param {ShapeDefinition} definition
* @returns {boolean}
*/
isTheLogoValid(key, definition) {
return definition.layers.length === 2 && definition.cachedHash === LOGO_SHAPE;
}
/**
* @param {string} key
* @param {ShapeDefinition} definition
* @returns {boolean}
*/
isToTheMoonValid(key, definition) {
return definition.layers.length === 4 && definition.cachedHash === ROCKET_SHAPE;
}
/**
* @param {string} key
* @param {ShapeDefinition} definition
* @returns {boolean}
*/
isMillionBlueprintShapesValid(key, definition) {
return definition.cachedHash === BLUEPRINT_SHAPE &&
this.root.hubGoals.storedShapes[BLUEPRINT_SHAPE] >= ONE_MILLION;
}
/** @returns {boolean} */
isHundredShapesRelevant() {
return Object.keys(this.root.hubGoals.storedShapes).length < ONE_HUNDRED;
}
/**
* @param {string} key
* @returns {boolean}
*/
isHundredShapesValid(key) {
return Object.keys(this.root.hubGoals.storedShapes).length === ONE_HUNDRED;
}
}

View File

@ -23,7 +23,11 @@ const ACHIEVEMENT_IDS = {
[ACHIEVEMENTS.storage]: "<id>",
[ACHIEVEMENTS.freedom]: "<id>",
[ACHIEVEMENTS.networked]: "<id>",
[ACHIEVEMENTS.theLogo]: "<id>"
[ACHIEVEMENTS.theLogo]: "<id>",
[ACHIEVEMENTS.toTheMoon]: "<id>",
[ACHIEVEMENTS.millionBlueprintShapes]: "<id>",
[ACHIEVEMENTS.hundredShapes]: "<id>",
};
export class SteamAchievementProvider extends AchievementProviderInterface {
@ -32,87 +36,64 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
super(app);
this.initialized = false;
this.collection = new AchievementCollection(
Object.keys(ACHIEVEMENT_IDS),
this.activate.bind(this)
);
this.keys = Object.keys(ACHIEVEMENT_IDS);
this.collection = new AchievementCollection(this.keys, this.activate.bind(this));
logger.log("Steam achievement collection created");
}
initialize () {
if (!G_IS_STANDALONE) {
logger.warn("Steam listener isn't active. Achievements won't sync.");
return Promise.resolve();
}
this.ipc = getIPCRenderer();
return this.ipc.invoke("steam:is-initialized")
.then(initialized => {
if (!initialized) {
logger.warn("Steam failed to intialize. Achievements won't sync.");
return;
}
this.initialized = true;
logger.log("Steam achievement provider initialized");
})
.catch(err => {
logger.error("Steam achievement provider error", err);
throw err;
})
}
/**
* @param {string} key - Maps to an Achievement
* @param {*} [details] - Additional information as needed to validate
*/
unlock (key, details) {
if (!this.collection.has(key)) {
console.log("Achievement already unlocked", key);
return;
}
if (!this.collection.isValid(key, details)) {
console.log("Achievement is invalid", key);
return;
}
this.collection.unlock(key)
.then(() => {
logger.log("Achievement unlocked:", key);
})
.catch(err => {
logger.error("Failed to unlock achievement", err);
})
}
/**
* @param {string} key - Maps to an API ID for the achievement
* @returns {string}
*/
getApiId (key) {
return ACHIEVEMENT_IDS[key];
}
/**
* @param {Achievement} achievement
* @returns {Promise<void>}
*/
activate (achievement) {
if (!this.initialized) {
return Promise.resolve();
}
return this.ipc.invoke("steam:activate-achievement", this.getApiId(achievement.key))
}
/**
* @returns {boolean}
*/
hasAchievements() {
return true;
}
initialize (root) {
this.collection.initialize(root);
if (!G_IS_STANDALONE) {
logger.warn("Steam unavailable. Achievements won't sync.");
return Promise.resolve();
}
this.ipc = getIPCRenderer();
return this.ipc.invoke("steam:is-initialized")
.then(initialized => {
this.initialized = initialized;
if (!this.initialized) {
logger.warn("Steam failed to intialize. Achievements won't sync.");
} else {
logger.log("Steam achievement provider initialized");
}
})
.catch(err => {
logger.error("Steam achievement provider error", err);
throw err;
})
}
/**
* @param {string} key - An ACHIEVEMENTS key
* @returns {Promise<void>}
*/
activate (key) {
let promise;
if (!this.initialized) {
promise = Promise.resolve();
} else {
promise = this.ipc.invoke("steam:activate-achievement", ACHIEVEMENT_IDS[key]);
}
return promise
.then(() => {
logger.log("Achievement unlocked:", key);
})
.catch(err => {
logger.error("Failed to unlock achievement:", key, err);
})
}
}