diff --git a/src/js/game/achievement_proxy.js b/src/js/game/achievement_proxy.js index 5d918ee6..c38fdf47 100644 --- a/src/js/game/achievement_proxy.js +++ b/src/js/game/achievement_proxy.js @@ -24,30 +24,27 @@ export class AchievementProxy { } onLoad() { - if (this.provider.hasLoaded()) { - this.disabled = false; - return; - } - this.provider.onLoad(this.root) .then(() => { - logger.log("Listening for unlocked achievements"); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.darkMode); + logger.log("Recieving achievement signals"); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.darkMode); this.startSlice(); this.disabled = false; }) .catch(err => { this.disabled = true; logger.error("Ignoring achievement signals", err); - }) + }); } startSlice() { this.lastSlice = this.root.time.now(); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.play1h, this.lastSlice); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.play10h, this.lastSlice); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.play20h, this.lastSlice); + this.root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.play1h, this.lastSlice, + ACHIEVEMENTS.play10h, this.lastSlice, + ACHIEVEMENTS.play20h, this.lastSlice + ); } update() { diff --git a/src/js/game/blueprint.js b/src/js/game/blueprint.js index 66aecd40..9512adc5 100644 --- a/src/js/game/blueprint.js +++ b/src/js/game/blueprint.js @@ -149,7 +149,7 @@ export class Blueprint { */ tryPlace(root, tile) { return root.logic.performBulkOperation(() => { - let anyPlaced = false; + let count = 0; for (let i = 0; i < this.entities.length; ++i) { const entity = this.entities[i]; if (!root.logic.checkCanPlaceEntity(entity, tile)) { @@ -161,12 +161,15 @@ export class Blueprint { root.logic.freeEntityAreaBeforeBuild(clone); root.map.placeStaticEntity(clone); root.entityMgr.registerEntity(clone); - anyPlaced = true; + count++; } - root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.placeBlueprint, anyPlaced); + root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.placeBlueprint, count, + ACHIEVEMENTS.placeBp1000, count + ); - return anyPlaced; + return count !== 0; }); } } diff --git a/src/js/game/hud/parts/mass_selector.js b/src/js/game/hud/parts/mass_selector.js index 08a11769..d73e3be3 100644 --- a/src/js/game/hud/parts/mass_selector.js +++ b/src/js/game/hud/parts/mass_selector.js @@ -8,6 +8,7 @@ import { globalConfig } from "../../../core/config"; import { makeDiv, formatBigNumber, formatBigNumberFull } from "../../../core/utils"; import { DynamicDomAttach } from "../dynamic_dom_attach"; import { createLogger } from "../../../core/logging"; +import { ACHIEVEMENTS } from "../../../platform/achievement_provider"; import { enumMouseButton } from "../../camera"; import { T } from "../../../translations"; import { KEYMAPPINGS } from "../../key_action_mapper"; @@ -100,6 +101,7 @@ export class HUDMassSelector extends BaseHUDPart { */ const mapUidToEntity = this.root.entityMgr.getFrozenUidSearchMap(); + let count = 0; this.root.logic.performBulkOperation(() => { for (let i = 0; i < entityUids.length; ++i) { const uid = entityUids[i]; @@ -111,8 +113,12 @@ export class HUDMassSelector extends BaseHUDPart { if (!this.root.logic.tryDeleteBuilding(entity)) { logger.error("Error in mass delete, could not remove building"); + } else { + count++; } } + + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.destroy1000, count); }); // Clear uids later diff --git a/src/js/game/hud/parts/waypoints.js b/src/js/game/hud/parts/waypoints.js index b16bfa9e..1a0e3739 100644 --- a/src/js/game/hud/parts/waypoints.js +++ b/src/js/game/hud/parts/waypoints.js @@ -15,6 +15,7 @@ import { removeAllChildren, } from "../../../core/utils"; import { Vector } from "../../../core/vector"; +import { ACHIEVEMENTS } from "../../../platform/achievement_provider"; import { T } from "../../../translations"; import { BaseItem } from "../../base_item"; import { MetaHubBuilding } from "../../buildings/hub"; @@ -349,6 +350,10 @@ export class HUDWaypoints extends BaseHUDPart { T.ingame.waypoints.creationSuccessNotification, enumNotificationType.success ); + this.root.signals.achievementCheck.dispatch( + ACHIEVEMENTS.mapMarkers15, + this.waypoints.length - 1 // Disregard HUB + ); // Re-render the list and thus add it this.rerenderWaypointList(); diff --git a/src/js/game/root.js b/src/js/game/root.js index 9396e67a..1fc4c31a 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -181,7 +181,8 @@ export class GameRoot { freeEntityAreaBeforeBuild: /** @type {TypedSignal<[Entity]>} */ (new Signal()), // Called with an achievement key and necessary args to validate it can be unlocked. - achievementUnlocked: /** @type {TypedSignal<[string, ...*]>} */ (new Signal()), + achievementCheck: /** @type {TypedSignal<[string, *]>} */ (new Signal()), + bulkAchievementCheck: /** @type {TypedSignal<[string, ...*]>} */ (new Signal()), }; // RNG's diff --git a/src/js/game/shape_definition_manager.js b/src/js/game/shape_definition_manager.js index 8178e7d3..13bc614a 100644 --- a/src/js/game/shape_definition_manager.js +++ b/src/js/game/shape_definition_manager.js @@ -97,7 +97,7 @@ export class ShapeDefinitionManager extends BasicSerializableObject { const rightSide = definition.cloneFilteredByQuadrants([2, 3]); const leftSide = definition.cloneFilteredByQuadrants([0, 1]); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.cutShape); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.cutShape); return /** @type {[ShapeDefinition, ShapeDefinition]} */ (this.operationCache[key] = [ this.registerOrReturnHandle(rightSide), @@ -140,7 +140,7 @@ export class ShapeDefinitionManager extends BasicSerializableObject { const rotated = definition.cloneRotateCW(); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.rotateShape); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.rotateShape); return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle( rotated @@ -195,7 +195,7 @@ export class ShapeDefinitionManager extends BasicSerializableObject { return /** @type {ShapeDefinition} */ (this.operationCache[key]); } - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.stackShape); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.stackShape); const stacked = lowerDefinition.cloneAndStackWith(upperDefinition); return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle( @@ -215,7 +215,7 @@ export class ShapeDefinitionManager extends BasicSerializableObject { return /** @type {ShapeDefinition} */ (this.operationCache[key]); } - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.paintShape); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.paintShape); const colorized = definition.cloneAndPaintWith(color); return /** @type {ShapeDefinition} */ (this.operationCache[key] = this.registerOrReturnHandle( @@ -252,11 +252,14 @@ export class ShapeDefinitionManager extends BasicSerializableObject { } this.shapeKeyToDefinition[id] = definition; - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.oldLevel17, definition); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.produceLogo, definition); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.produceMsLogo, definition); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.produceRocket, definition); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.stack4Layers, definition); + this.root.signals.bulkAchievementCheck.dispatch( + ACHIEVEMENTS.logoBefore18, definition, + ACHIEVEMENTS.oldLevel17, definition, + ACHIEVEMENTS.produceLogo, definition, + ACHIEVEMENTS.produceMsLogo, definition, + ACHIEVEMENTS.produceRocket, definition, + ACHIEVEMENTS.stack4Layers, 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 82d10974..acd87e52 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -276,7 +276,7 @@ export class ItemEjectorSystem extends GameSystemWithFilter { if (storageComp.canAcceptItem(item)) { storageComp.takeItem(item); - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.storeShape, storageComp); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.storeShape, storageComp); return true; } diff --git a/src/js/game/systems/wire.js b/src/js/game/systems/wire.js index d18c9380..0491def6 100644 --- a/src/js/game/systems/wire.js +++ b/src/js/game/systems/wire.js @@ -698,7 +698,7 @@ export class WireSystem extends GameSystemWithFilter { return; } - this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.place5000Wires, entity); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.place5000Wires, 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 046f9ecc..6345a61b 100644 --- a/src/js/platform/achievement_provider.js +++ b/src/js/platform/achievement_provider.js @@ -13,14 +13,18 @@ export const ACHIEVEMENTS = { completeLvl26: "completeLvl26", cutShape: "cutShape", darkMode: "darkMode", + destroy1000: "destroy1000", irrelevantShape: "irrelevantShape", level100: "level100", level50: "level50", + logoBefore18: "logoBefore18", + mapMarkers15: "mapMarkers15", oldLevel17: "oldLevel17", openWires: "openWires", paintShape: "paintShape", place5000Wires: "place5000Wires", placeBlueprint: "placeBlueprint", + placeBp1000: "placeBp1000", play1h: "play1h", play10h: "play10h", play20h: "play20h", @@ -33,6 +37,8 @@ export const ACHIEVEMENTS = { store100Unique: "store100Unique", storeShape: "storeShape", unlockWires: "unlockWires", + upgradesTier5: "upgradesTier5", + upgradesTier8: "upgradesTier8", }; const DARK_MODE = "dark"; @@ -149,14 +155,23 @@ export class AchievementCollection { this.createAndSet(ACHIEVEMENTS.darkMode, { isValid: this.isDarkModeValid, }); - /* - *this.createAndSet(ACHIEVEMENTS.irrelevantShape, { - * isValid: this.isIrrelevantShapeValid, - * signal: "shapeDelivered", - *}); - */ + this.createAndSet(ACHIEVEMENTS.destroy1000, { + isValid: this.isDestroy1000Valid, + }); + this.createAndSet(ACHIEVEMENTS.irrelevantShape, { + isValid: this.isIrrelevantShapeValid, + signal: "shapeDelivered", + }); this.createAndSet(ACHIEVEMENTS.level100, this.createLevelOptions(100)); this.createAndSet(ACHIEVEMENTS.level50, this.createLevelOptions(50)); + this.createAndSet(ACHIEVEMENTS.logoBefore18, { + isRelevant: this.isLogoBefore18Relevant, + isValid: this.isLogoBefore18Valid, + }); + this.createAndSet(ACHIEVEMENTS.mapMarkers15, { + isRelevant: this.isMapMarkers15Relevant, + isValid: this.isMapMarkers15Valid, + }); this.createAndSet(ACHIEVEMENTS.oldLevel17, this.createShapeOptions(SHAPE_OLD_LEVEL_17)); this.createAndSet(ACHIEVEMENTS.openWires, { isValid: this.isOpenWiresValid, @@ -169,6 +184,9 @@ export class AchievementCollection { this.createAndSet(ACHIEVEMENTS.placeBlueprint, { isValid: this.isPlaceBlueprintValid, }); + this.createAndSet(ACHIEVEMENTS.placeBp1000, { + isValid: this.isPlaceBp1000Valid, + }); this.createAndSet(ACHIEVEMENTS.play1h, this.createTimeOptions(HOUR_1)); this.createAndSet(ACHIEVEMENTS.play10h, this.createTimeOptions(HOUR_10)); this.createAndSet(ACHIEVEMENTS.play20h, this.createTimeOptions(HOUR_20)); @@ -190,12 +208,15 @@ export class AchievementCollection { signal: "entityGotNewComponent", }); this.createAndSet(ACHIEVEMENTS.unlockWires, this.createLevelOptions(20)); + this.createAndSet(ACHIEVEMENTS.upgradesTier5, this.createUpgradeOptions(5)); + this.createAndSet(ACHIEVEMENTS.upgradesTier8, this.createUpgradeOptions(8)); } /** @param {GameRoot} root */ initialize(root) { this.root = root; - this.root.signals.achievementUnlocked.add(this.unlock, this); + this.root.signals.achievementCheck.add(this.unlock, this); + this.root.signals.bulkAchievementCheck.add(this.bulkUnlock, this); for (let [key, achievement] of this.map.entries()) { if (!achievement.isRelevant()) { @@ -210,7 +231,7 @@ export class AchievementCollection { } if (!this.hasDefaultReceivers()) { - this.root.signals.achievementUnlocked.remove(this.unlock); + this.root.signals.achievementCheck.remove(this.unlock); } } @@ -222,6 +243,10 @@ export class AchievementCollection { * @param {string} [options.signal] */ createAndSet(key, options = {}) { + if (G_IS_DEV) { + assert(ACHIEVEMENTS[key], "Achievement key not found: ", key); + } + const achievement = new Achievement(key); achievement.activate = this.activate; @@ -241,6 +266,12 @@ export class AchievementCollection { this.map.set(key, achievement); } + bulkUnlock() { + for (let i = 0; i < arguments.length; i += 2) { + this.unlock(arguments[i], arguments[i + 1]); + } + } + /** * @param {string} key - Maps to an Achievement * @param {*[]} [arguments] - Additional arguments received from signal dispatches @@ -271,11 +302,11 @@ export class AchievementCollection { * @param {?Error} err - Error is null if activation was successful * @param {string} key - Maps to an Achievement */ - onActivate (err, key) { + onActivate(err, key) { this.remove(key); if (!this.hasDefaultReceivers()) { - this.root.signals.achievementUnlocked.remove(this.unlock); + this.root.signals.achievementCheck.remove(this.unlock); } } @@ -304,25 +335,45 @@ export class AchievementCollection { return false; } - createLevelOptions (level) { + hasAllUpgradesAtTier(tier) { + const upgrades = this.root.gameMode.getUpgrades(); + + for (let upgradeId in upgrades) { + if (this.root.hubGoals.getUpgradeLevel(upgradeId) < tier - 1) { + return false; + } + } + + return true; + } + + createLevelOptions(level) { return { isRelevant: () => this.root.hubGoals.level < level, isValid: (key, currentLevel) => currentLevel === level, - signal: "storyGoalCompleted" - } + signal: "storyGoalCompleted", + }; } - createShapeOptions (shape) { + createShapeOptions(shape) { return { - isValid: (key, definition) => definition.cachedHash === shape - } + isValid: (key, definition) => definition.cachedHash === shape, + }; } - createTimeOptions (duration) { + createTimeOptions(duration) { return { isRelevant: () => this.root.time.now() < duration, isValid: () => this.root.time.now() >= duration, - } + }; + } + + createUpgradeOptions(tier) { + return { + isRelevant: () => !this.hasAllUpgradesAtTier(tier), + isValid: () => this.hasAllUpgradesAtTier(tier), + signal: "upgradePurchased", + }; } /** @@ -340,8 +391,10 @@ export class AchievementCollection { * @returns {boolean} */ isBlueprint100kValid(key, definition) { - return definition.cachedHash === SHAPE_BLUEPRINT && - this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 100000; + return ( + definition.cachedHash === SHAPE_BLUEPRINT && + this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 100000 + ); } /** @@ -350,8 +403,10 @@ export class AchievementCollection { * @returns {boolean} */ isBlueprint1mValid(key, definition) { - return definition.cachedHash === SHAPE_BLUEPRINT && - this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 1000000; + return ( + definition.cachedHash === SHAPE_BLUEPRINT && + this.root.hubGoals.storedShapes[SHAPE_BLUEPRINT] >= 1000000 + ); } /** @@ -362,13 +417,66 @@ export class AchievementCollection { return this.root.app.settings.currentData.settings.theme === DARK_MODE; } + /** + * @param {string} key + * @param {number} count - The count of selected entities destroyed + * @returns {boolean} + */ + isDestroy1000Valid(key, count) { + return count >= 1000; + } + /** * @param {string} key * @param {ShapeDefinition} definition * @returns {boolean} */ isIrrelevantShapeValid(key, definition) { - //return definition.cachedHash !== this.hubGoals.currentGoal.definition.cachedHash + if (definition.cachedHash === this.root.hubGoals.currentGoal.definition.cachedHash) { + return false; + } + + const upgrades = this.root.gameMode.getUpgrades(); + for (let upgradeId in upgrades) { + const currentTier = this.root.hubGoals.getUpgradeLevel(upgradeId); + const requiredShapes = upgrades[upgradeId][currentTier].required; + + for (let i = 0; i < requiredShapes.length; i++) { + if (definition.cachedHash === requiredShapes[i].shape) { + return false; + } + } + } + + return true; + } + + /** @returns {boolean} */ + isLogoBefore18Relevant() { + return this.root.hubGoals.level < 18; + } + + /** + * @param {string} key + * @param {ShapeDefinition} definition + * @returns {boolean} + */ + isLogoBefore18Valid(key, definition) { + return this.root.hubGoals.level < 18 && definition.cachedHash === SHAPE_LOGO; + } + + /** @returns {boolean} */ + isMapMarkers15Relevant() { + return this.root.hud.parts.waypoints.waypoints.length < 16; // 16 - HUB + } + + /** + * @param {string} key + * @param {number} count - Count of map markers excluding HUB + * @returns {boolean} + */ + isMapMarkers15Valid(key, count) { + return count === 15; } /** @@ -386,18 +494,29 @@ export class AchievementCollection { * @returns {boolean} */ isPlace5000WiresValid(key, entity) { - return entity.components.Wire && + return ( + entity.components.Wire && entity.registered && - entity.root.entityMgr.componentToEntity.Wire.length === 5000; + entity.root.entityMgr.componentToEntity.Wire.length === 5000 + ); } /** * @param {string} key - * @param {boolean} anyPlaced + * @param {number} count * @returns {boolean} */ - isPlaceBlueprintValid(key, anyPlaced) { - return anyPlaced; + isPlaceBlueprintValid(key, count) { + return count != 0; + } + + /** + * @param {string} key + * @param {number} count + * @returns {boolean} + */ + isPlaceBp1000Valid(key, count) { + return count >= 1000; } /** diff --git a/src/js/platform/browser/no_achievement_provider.js b/src/js/platform/browser/no_achievement_provider.js index 91972bf7..8a8a343e 100644 --- a/src/js/platform/browser/no_achievement_provider.js +++ b/src/js/platform/browser/no_achievement_provider.js @@ -14,7 +14,7 @@ export class NoAchievementProvider extends AchievementProviderInterface { } onLoad() { - return Promise.resolve(); + return Promise.reject(new Error("No achievements to load")); } activate() { diff --git a/src/js/platform/electron/steam_achievement_provider.js b/src/js/platform/electron/steam_achievement_provider.js index 9001b909..f797c903 100644 --- a/src/js/platform/electron/steam_achievement_provider.js +++ b/src/js/platform/electron/steam_achievement_provider.js @@ -16,14 +16,18 @@ const ACHIEVEMENT_IDS = { [ACHIEVEMENTS.completeLvl26]: "complete_lvl_26", [ACHIEVEMENTS.cutShape]: "cut_shape", [ACHIEVEMENTS.darkMode]: "dark_mode", + [ACHIEVEMENTS.destroy1000]: "destroy_1000", [ACHIEVEMENTS.irrelevantShape]: "irrelevant_shape", [ACHIEVEMENTS.level100]: "level_100", [ACHIEVEMENTS.level50]: "level_50", + [ACHIEVEMENTS.logoBefore18]: "logo_before_18", + [ACHIEVEMENTS.mapMarkers15]: "map_markers_15", [ACHIEVEMENTS.openWires]: "open_wires", [ACHIEVEMENTS.oldLevel17]: "old_level_17", [ACHIEVEMENTS.paintShape]: "paint_shape", [ACHIEVEMENTS.place5000Wires]: "place_5000_wires", [ACHIEVEMENTS.placeBlueprint]: "place_blueprint", + [ACHIEVEMENTS.placeBp1000]: "place_bp_1000", [ACHIEVEMENTS.play1h]: "play_1h", [ACHIEVEMENTS.play10h]: "play_10h", [ACHIEVEMENTS.play20h]: "play_20h", @@ -36,6 +40,8 @@ const ACHIEVEMENT_IDS = { [ACHIEVEMENTS.store100Unique]: "store_100_unique", [ACHIEVEMENTS.storeShape]: "store_shape", [ACHIEVEMENTS.unlockWires]: "unlock_wires", + [ACHIEVEMENTS.upgradesTier5]: "upgrades_tier_5", + [ACHIEVEMENTS.upgradesTier8]: "upgrades_tier_8", }; export class SteamAchievementProvider extends AchievementProviderInterface { @@ -44,9 +50,15 @@ export class SteamAchievementProvider extends AchievementProviderInterface { super(app); this.initialized = false; - this.loaded = false; + this.saveId = null; this.collection = new AchievementCollection(this.activate.bind(this)); + if (G_IS_DEV) { + for (let key in ACHIEVEMENT_IDS) { + assert(this.collection.map.has(key), "Key not found in collection: " + key); + } + } + logger.log("Collection created with", this.collection.map.size, "achievements"); } @@ -55,27 +67,26 @@ export class SteamAchievementProvider extends AchievementProviderInterface { return true; } - /** @returns {boolean} */ - hasLoaded() { - return this.loaded; - } - /** * @param {GameRoot} root * @returns {Promise} */ onLoad(root) { - if (this.loaded) { - return Promise.resolve(); - } + this.root = root; try { - this.collection.initialize(root); - this.loaded = true; - logger.log(this.collection.map.size, "achievements are relevant and initialized"); + if (!this.saveId || this.saveId === this.root.savegame.internalId) { + this.collection.initialize(root); + } else { + this.collection = new AchievementCollection(this.activate.bind(this)); + this.collection.initialize(root); + } + + logger.log("Initialized", this.collection.map.size, "relevant achievements"); + this.saveId = this.root.savegame.internalId; return Promise.resolve(); } catch (err) { - logger.error("Failed to initialize the achievement collection"); + logger.error("Failed to initialize the collection"); return Promise.reject(err); } }