From 835317477dcf735c9ca2de35eb3f5402925feb72 Mon Sep 17 00:00:00 2001 From: Greg Considine Date: Wed, 3 Mar 2021 11:59:56 -0500 Subject: [PATCH] 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 --- src/js/game/achievement_proxy.js | 8 +- src/js/game/blueprint.js | 4 +- src/js/game/hub_goals.js | 7 - src/js/game/shape_definition_manager.js | 1 + src/js/game/systems/item_ejector.js | 4 +- src/js/game/systems/wire.js | 6 +- src/js/platform/achievement_provider.js | 286 ++++++++++++++---- .../electron/steam_achievement_provider.js | 129 ++++---- 8 files changed, 291 insertions(+), 154 deletions(-) diff --git a/src/js/game/achievement_proxy.js b/src/js/game/achievement_proxy.js index 8e8dd802..79519038 100644 --- a/src/js/game/achievement_proxy.js +++ b/src/js/game/achievement_proxy.js @@ -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 => { diff --git a/src/js/game/blueprint.js b/src/js/game/blueprint.js index 3968a059..aeb22169 100644 --- a/src/js/game/blueprint.js +++ b/src/js/game/blueprint.js @@ -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; }); diff --git a/src/js/game/hub_goals.js b/src/js/game/hub_goals.js index 0c4cd9b5..9a945128 100644 --- a/src/js/game/hub_goals.js +++ b/src/js/game/hub_goals.js @@ -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); - } } /** diff --git a/src/js/game/shape_definition_manager.js b/src/js/game/shape_definition_manager.js index 428f33fd..07ea3df7 100644 --- a/src/js/game/shape_definition_manager.js +++ b/src/js/game/shape_definition_manager.js @@ -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; diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index ebb3a16e..1033a0fa 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -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; } diff --git a/src/js/game/systems/wire.js b/src/js/game/systems/wire.js index 25431efb..a36fe30b 100644 --- a/src/js/game/systems/wire.js +++ b/src/js/game/systems/wire.js @@ -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(); diff --git a/src/js/platform/achievement_provider.js b/src/js/platform/achievement_provider.js index 85f57304..99856372 100644 --- a/src/js/platform/achievement_provider.js +++ b/src/js/platform/achievement_provider.js @@ -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} - */ - 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; } } diff --git a/src/js/platform/electron/steam_achievement_provider.js b/src/js/platform/electron/steam_achievement_provider.js index 19121681..a03e24ae 100644 --- a/src/js/platform/electron/steam_achievement_provider.js +++ b/src/js/platform/electron/steam_achievement_provider.js @@ -23,7 +23,11 @@ const ACHIEVEMENT_IDS = { [ACHIEVEMENTS.storage]: "", [ACHIEVEMENTS.freedom]: "", [ACHIEVEMENTS.networked]: "", - [ACHIEVEMENTS.theLogo]: "" + [ACHIEVEMENTS.theLogo]: "", + [ACHIEVEMENTS.toTheMoon]: "", + [ACHIEVEMENTS.millionBlueprintShapes]: "", + + [ACHIEVEMENTS.hundredShapes]: "", }; 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} - */ - 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} + */ + 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); + }) + } }