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

Add achievements and accommodations for switching states

- Fix startup code to avoid clobbering achievements on state switch
- Add a few more achievements
This commit is contained in:
Greg Considine 2021-03-03 17:30:14 -05:00
parent 835317477d
commit f5bfd9cf07
8 changed files with 237 additions and 102 deletions

View File

@ -3,6 +3,7 @@ import { GameRoot } from "./root";
/* typehints:end */
import { createLogger } from "../core/logging";
import { ACHIEVEMENTS } from "../platform/achievement_provider";
const logger = createLogger("achievement_proxy");
@ -20,9 +21,10 @@ export class AchievementProxy {
}
onLoad() {
this.provider.initialize(this.root)
this.provider.onLoad(this.root)
.then(() => {
logger.log("Listening for unlocked achievements");
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.darkMode);
})
.catch(err => {
logger.error("Ignoring achievement signals", err);

View File

@ -180,8 +180,8 @@ export class GameRoot {
// for freeing space before actually placing.
freeEntityAreaBeforeBuild: /** @type {TypedSignal<[Entity]>} */ (new Signal()),
// Called with the key of the unlocked achievement
achievementUnlocked: /** @type {TypedSignal<[string]>} */ (new Signal()),
// Called with an achievement key and necessary args to validate it can be unlocked.
achievementUnlocked: /** @type {TypedSignal<[string, ...*]>} */ (new Signal()),
};
// RNG's

View File

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

View File

@ -7,30 +7,34 @@ import { ShapeDefinition } from "../game/shape_definition";
/* typehints:end */
export const ACHIEVEMENTS = {
painting: "painting",
blueprints: "blueprints",
cutting: "cutting",
darkMode: "darkMode",
fourLayers: "fourLayers",
freedom: "freedom",
hundredShapes: "hundredShapes",
longBelt: "longBelt",
millionBlueprintShapes: "millionBlueprintShapes",
networked: "networked",
painting: "painting",
rotating: "rotating",
stacking: "stacking",
blueprints: "blueprints",
wires: "wires",
storage: "storage",
freedom: "freedom",
networked: "networked",
theLogo: "theLogo",
toTheMoon: "toTheMoon",
millionBlueprintShapes: "millionBlueprintShapes",
hundredShapes: "hundredShapes",
wires: "wires",
};
const BLUEPRINT_SHAPE = "CbCbCbRb:CwCwCwCw";
const DARK_MODE = "dark";
const FREEDOM_LEVEL = 26;
const LOGO_SHAPE = "RuCw--Cw:----Ru--";
const LONG_BELT_COUNT = 200;
const NETWORKED_WIRE_COUNT = 100;
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 */
@ -47,6 +51,26 @@ export class AchievementProviderInterface {
return Promise.reject();
}
/**
* Opportunity to do additional initialization work with the GameRoot.
* @param {GameRoot} root
* @returns {Promise<void>}
*/
onLoad(root) {
abstract;
return Promise.reject();
}
/**
* Call to activate an achievement with the provider
* @param {string} key - Maps to an Achievement
* @returns {Promise<void>}
*/
activate(key) {
abstract;
return Promise.reject();
}
/**
* Checks if achievements are supported in the current build
* @returns {boolean}
@ -61,14 +85,17 @@ export class Achievement {
/** @param {string} key - An ACHIEVEMENTS key */
constructor(key) {
this.key = key;
this.unlocked = false;
this.signal = null;
this.receiver = null;
this.activate = null;
this.activatePromise = null;
this.receiver = null;
this.signal = null;
}
isValid () {
isValid() {
return true;
}
isRelevant() {
return true;
}
@ -83,18 +110,62 @@ export class Achievement {
export class AchievementCollection {
/**
* @param {string[]} keys - An array of ACHIEVEMENTS keys
* @param {function} [activate] - Resolves when provider activation is complete
* @param {function} activate - Resolves when provider activation is complete
*/
constructor(keys, activate) {
constructor(activate) {
this.map = new Map();
this.activate = activate;
this.initialized = false;
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]);
}
this.createAndSet(ACHIEVEMENTS.blueprints, {
isValid: this.isBlueprintsValid
});
this.createAndSet(ACHIEVEMENTS.cutting);
this.createAndSet(ACHIEVEMENTS.darkMode, {
isValid: this.isDarkModeValid
});
this.createAndSet(ACHIEVEMENTS.fourLayers, {
isValid: this.isFourLayersValid
});
this.createAndSet(ACHIEVEMENTS.freedom, {
isRelevant: this.isFreedomRelevant,
isValid: this.isFreedomValid,
signal: "storyGoalCompleted"
});
this.createAndSet(ACHIEVEMENTS.hundredShapes, {
isRelevant: this.isHundredShapesRelevant,
isValid: this.isHundredShapesValid,
signal: "shapeDelivered"
});
this.createAndSet(ACHIEVEMENTS.longBelt, {
isValid: this.isLongBeltValid,
signal: "entityAdded"
});
this.createAndSet(ACHIEVEMENTS.millionBlueprintShapes, {
isValid: this.isMillionBlueprintShapesValid,
signal: "shapeDelivered"
});
this.createAndSet(ACHIEVEMENTS.networked, {
isValid: this.isNetworkedValid,
});
this.createAndSet(ACHIEVEMENTS.painting);
this.createAndSet(ACHIEVEMENTS.rotating);
this.createAndSet(ACHIEVEMENTS.stacking);
this.createAndSet(ACHIEVEMENTS.storage, {
isValid: this.isStorageValid,
signal: "entityGotNewComponent"
});
this.createAndSet(ACHIEVEMENTS.theLogo, {
isValid: this.isTheLogoValid
});
this.createAndSet(ACHIEVEMENTS.toTheMoon, {
isValid: this.isToTheMoonValid
});
this.createAndSet(ACHIEVEMENTS.wires, {
isRelevant: this.isWiresRelevant,
isValid: this.isWiresValid,
signal: "storyGoalCompleted"
});
}
/** @param {GameRoot} root */
@ -102,58 +173,49 @@ export class AchievementCollection {
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);
for (let [key, achievement] of this.map.entries()) {
if (!achievement.isRelevant()) {
this.remove(key);
continue;
}
if (this.isWiresRelevant()) {
this.createAndSet(ACHIEVEMENTS.wires, this.isWiresValid, "storyGoalCompleted");
if (achievement.signal) {
achievement.receiver = this.unlock.bind(this, key);
this.root.signals[achievement.signal].add(achievement.receiver);
}
}
this.createAndSet(ACHIEVEMENTS.storage, this.isStorageValid, "entityGotNewComponent");
if (this.isFreedomRelevant()) { // ...is it?
this.createAndSet(ACHIEVEMENTS.freedom, this.isFreedomValid, "storyGoalCompleted");
if (!this.hasDefaultReceivers()) {
this.root.signals.achievementUnlocked.remove(this.unlock);
}
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"
);
}
this.initialized = true;
}
/**
* @param {string} key - Maps to an Achievement
* @param {function} [isValid] - Validates achievement when a signal message is received
* @param {string} [signal] - Signal name to listen to for unlock attempts
* @param {object} [options]
* @param {function} [options.isValid]
* @param {function} [options.isRelevant]
* @param {string} [options.signal]
*/
createAndSet(key, isValid, signal) {
createAndSet(key, options) {
const achievement = new Achievement(key);
achievement.activate = this.activate;
if (isValid) {
achievement.isValid = isValid.bind(this);
}
if (options) {
if (options.isValid) {
achievement.isValid = options.isValid.bind(this);
}
if (signal) {
achievement.signal = signal;
achievement.receiver = this.unlock.bind(this, key);
this.root.signals[achievement.signal].add(achievement.receiver);
if (options.isRelevant) {
achievement.isRelevant = options.isRelevant.bind(this);
}
if (options.signal) {
achievement.signal = options.signal;
}
}
this.map.set(key, achievement);
@ -165,39 +227,42 @@ export class AchievementCollection {
*/
unlock(key) {
if (!this.map.has(key)) {
console.log("Achievement unlocked or irrelevant:", key);
return;
}
const achievement = this.map.get(key);
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);
this.remove(key);
if (!this.hasDefaultReceivers()) {
this.root.signals.achievementUnlocked.remove(this.unlock);
console.log("removed achievementUnlocked receiver");
}
});
}
/** @param {string} key - Maps to an Achievement */
remove(key) {
const achievement = this.map.get(key);
if (achievement.receiver) {
this.root.signals[achievement.signal].remove(achievement.receiver);
}
this.map.delete(key);
}
hasDefaultReceivers() {
if (!this.map.size) {
return false;
}
for(let achievement of this.map.values()) {
for (let achievement of this.map.values()) {
if (!achievement.signal) {
return true;
}
@ -303,4 +368,32 @@ export class AchievementCollection {
isHundredShapesValid(key) {
return Object.keys(this.root.hubGoals.storedShapes).length === ONE_HUNDRED;
}
/**
* @param {string} key
* @param {ShapeDefinition} definition
* @returns {boolean}
*/
isFourLayersValid(key, definition) {
return definition.layers.length === 4;
}
/**
* @param {string} key
* @param {Entity} entity
* @returns {boolean}
*/
isLongBeltValid(key, entity) {
return entity.components.Belt &&
entity.components.Belt.assignedPath.totalLength >= LONG_BELT_COUNT;
}
/**
* @param {string} key
* @returns {boolean}
*/
isDarkModeValid(key) {
return this.root.app.settings.currentData.settings.theme === DARK_MODE;
}
}

View File

@ -9,6 +9,11 @@ export class NoAchievementProvider extends AchievementProviderInterface {
return Promise.resolve();
}
unlock() {
onLoad() {
return Promise.resolve();
}
activate() {
return Promise.resolve();
}
}

View File

@ -6,6 +6,7 @@ import { GamedistributionAdProvider } from "../ad_providers/gamedistribution";
import { NoAdProvider } from "../ad_providers/no_ad_provider";
import { SteamAchievementProvider } from "../electron/steam_achievement_provider";
import { PlatformWrapperInterface } from "../wrapper";
import { NoAchievementProvider } from "./no_achievement_provider";
import { StorageImplBrowser } from "./storage";
import { StorageImplBrowserIndexedDB } from "./storage_indexed_db";
@ -70,13 +71,9 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface {
logger.log("Embed provider:", this.embedProvider.id);
if (G_IS_DEV && globalConfig.debug.testAchievements) {
logger.log("Testing achievements");
this.app.achievementProvider = new SteamAchievementProvider(this.app);
}
return this.detectStorageImplementation()
.then(() => this.initializeAdProvider())
.then(() => this.initializeAchievementProvider())
.then(() => super.initialize());
}
@ -202,6 +199,21 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface {
});
}
initializeAchievementProvider() {
if (G_IS_DEV && globalConfig.debug.testAchievements) {
this.app.achievementProvider = new SteamAchievementProvider(this.app);
return this.app.achievementProvider.initialize()
.catch(err => {
logger.error("Failed to initialize achievement provider, disabling:", err);
this.app.achievementProvider = new NoAchievementProvider(this.app);
});
}
return this.app.achievementProvider.initialize();
}
exitApp() {
// Can not exit app
}

View File

@ -1,6 +1,6 @@
/* typehints:start */
import { Application } from "../../application";
import { Achievement } from "../achievement_provider";
import { GameRoot } from "../../game/root";
/* typehints:end */
import { createLogger } from "../../core/logging";
@ -14,20 +14,22 @@ import {
const logger = createLogger("achievements/steam");
const ACHIEVEMENT_IDS = {
[ACHIEVEMENTS.painting]: "<id>",
[ACHIEVEMENTS.blueprints]: "<id>",
[ACHIEVEMENTS.cutting]: "achievement_01", // Test ID
[ACHIEVEMENTS.darkMode]: "<id>",
[ACHIEVEMENTS.fourLayers]: "<id>",
[ACHIEVEMENTS.freedom]: "<id>",
[ACHIEVEMENTS.hundredShapes]: "<id>",
[ACHIEVEMENTS.longBelt]: "<id>",
[ACHIEVEMENTS.millionBlueprintShapes]: "<id>",
[ACHIEVEMENTS.networked]: "<id>",
[ACHIEVEMENTS.painting]: "<id>",
[ACHIEVEMENTS.rotating]: "<id>",
[ACHIEVEMENTS.stacking]: "<id>",
[ACHIEVEMENTS.blueprints]: "<id>",
[ACHIEVEMENTS.wires]: "<id>",
[ACHIEVEMENTS.storage]: "<id>",
[ACHIEVEMENTS.freedom]: "<id>",
[ACHIEVEMENTS.networked]: "<id>",
[ACHIEVEMENTS.theLogo]: "<id>",
[ACHIEVEMENTS.toTheMoon]: "<id>",
[ACHIEVEMENTS.millionBlueprintShapes]: "<id>",
[ACHIEVEMENTS.hundredShapes]: "<id>",
[ACHIEVEMENTS.wires]: "<id>",
};
export class SteamAchievementProvider extends AchievementProviderInterface {
@ -36,10 +38,9 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
super(app);
this.initialized = false;
this.keys = Object.keys(ACHIEVEMENT_IDS);
this.collection = new AchievementCollection(this.keys, this.activate.bind(this));
this.collection = new AchievementCollection(this.activate.bind(this));
logger.log("Steam achievement collection created");
logger.log("Collection created with", this.collection.map.size, "achievements");
}
/**
@ -49,9 +50,23 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
return true;
}
initialize (root) {
this.collection.initialize(root);
/** @param {GameRoot} root */
onLoad(root) {
if (this.collection.initialized) {
return Promise.resolve();
}
try {
this.collection.initialize(root);
logger.log(this.collection.map.size, "achievements are relevant and initialized");
return Promise.resolve();
} catch (err) {
logger.error("Failed to initialize the achievement collection");
return Promise.reject(err);
}
}
initialize() {
if (!G_IS_STANDALONE) {
logger.warn("Steam unavailable. Achievements won't sync.");
return Promise.resolve();
@ -68,11 +83,7 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
} else {
logger.log("Steam achievement provider initialized");
}
})
.catch(err => {
logger.error("Steam achievement provider error", err);
throw err;
})
});
}
/**
@ -90,10 +101,10 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
return promise
.then(() => {
logger.log("Achievement unlocked:", key);
logger.log("Achievement activated:", key);
})
.catch(err => {
logger.error("Failed to unlock achievement:", key, err);
logger.error("Failed to activate achievement:", key, err);
})
}
}

View File

@ -1,3 +1,4 @@
import { NoAchievementProvider } from "../browser/no_achievement_provider";
import { PlatformWrapperImplBrowser } from "../browser/wrapper";
import { getIPCRenderer } from "../../core/utils";
import { createLogger } from "../../core/logging";
@ -22,7 +23,8 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
this.app.storage = new StorageImplElectron(this);
this.app.achievementProvider = new SteamAchievementProvider(this.app);
return PlatformWrapperInterface.prototype.initialize.call(this);
return this.initializeAchievementProvider()
.then(() => PlatformWrapperInterface.prototype.initialize.call(this));
}
steamOverlayFixRedrawCanvas() {
@ -55,6 +57,15 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
return Promise.resolve();
}
initializeAchievementProvider() {
return this.app.achievementProvider.initialize()
.catch(err => {
logger.error("Failed to initialize achievement provider, disabling:", err);
this.app.achievementProvider = new NoAchievementProvider(this.app);
});
}
getSupportsFullscreen() {
return true;
}