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

View File

@ -180,8 +180,8 @@ export class GameRoot {
// for freeing space before actually placing. // for freeing space before actually placing.
freeEntityAreaBeforeBuild: /** @type {TypedSignal<[Entity]>} */ (new Signal()), freeEntityAreaBeforeBuild: /** @type {TypedSignal<[Entity]>} */ (new Signal()),
// Called with the key of the unlocked achievement // Called with an achievement key and necessary args to validate it can be unlocked.
achievementUnlocked: /** @type {TypedSignal<[string]>} */ (new Signal()), achievementUnlocked: /** @type {TypedSignal<[string, ...*]>} */ (new Signal()),
}; };
// RNG's // 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.theLogo, definition);
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.toTheMoon, 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); // logger.log("Registered shape with key (2)", id);
return definition; return definition;

View File

@ -7,30 +7,34 @@ import { ShapeDefinition } from "../game/shape_definition";
/* typehints:end */ /* typehints:end */
export const ACHIEVEMENTS = { export const ACHIEVEMENTS = {
painting: "painting", blueprints: "blueprints",
cutting: "cutting", cutting: "cutting",
darkMode: "darkMode",
fourLayers: "fourLayers",
freedom: "freedom",
hundredShapes: "hundredShapes",
longBelt: "longBelt",
millionBlueprintShapes: "millionBlueprintShapes",
networked: "networked",
painting: "painting",
rotating: "rotating", rotating: "rotating",
stacking: "stacking", stacking: "stacking",
blueprints: "blueprints",
wires: "wires",
storage: "storage", storage: "storage",
freedom: "freedom",
networked: "networked",
theLogo: "theLogo", theLogo: "theLogo",
toTheMoon: "toTheMoon", toTheMoon: "toTheMoon",
millionBlueprintShapes: "millionBlueprintShapes", wires: "wires",
hundredShapes: "hundredShapes",
}; };
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_HUNDRED = 100;
const ONE_MILLION = 1000000; const ONE_MILLION = 1000000;
const BLUEPRINT_SHAPE = "CbCbCbRb:CwCwCwCw";
const LOGO_SHAPE = "RuCw--Cw:----Ru--";
const ROCKET_SHAPE = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw"; const ROCKET_SHAPE = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw";
const FREEDOM_LEVEL = 26;
const WIRES_LEVEL = 20; const WIRES_LEVEL = 20;
const NETWORKED_WIRE_COUNT = 100;
export class AchievementProviderInterface { export class AchievementProviderInterface {
/** @param {Application} app */ /** @param {Application} app */
@ -47,6 +51,26 @@ export class AchievementProviderInterface {
return Promise.reject(); 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 * Checks if achievements are supported in the current build
* @returns {boolean} * @returns {boolean}
@ -61,14 +85,17 @@ export class Achievement {
/** @param {string} key - An ACHIEVEMENTS key */ /** @param {string} key - An ACHIEVEMENTS key */
constructor(key) { constructor(key) {
this.key = key; this.key = key;
this.unlocked = false;
this.signal = null;
this.receiver = null;
this.activate = null; this.activate = null;
this.activatePromise = null; this.activatePromise = null;
this.receiver = null;
this.signal = null;
} }
isValid () { isValid() {
return true;
}
isRelevant() {
return true; return true;
} }
@ -83,18 +110,62 @@ export class Achievement {
export class AchievementCollection { 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.map = new Map();
this.activate = activate; this.activate = activate;
this.initialized = false;
assert(Object.keys(ACHIEVEMENTS).length === keys.length, "Mismatched achievements"); this.createAndSet(ACHIEVEMENTS.blueprints, {
isValid: this.isBlueprintsValid
for (var i = 0; i < keys.length; i++) { });
assert(ACHIEVEMENTS[keys[i]], "Achievement does not exist: " + keys[i]); 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 */ /** @param {GameRoot} root */
@ -102,58 +173,49 @@ export class AchievementCollection {
this.root = root; this.root = root;
this.root.signals.achievementUnlocked.add(this.unlock, this); this.root.signals.achievementUnlocked.add(this.unlock, this);
this.createAndSet(ACHIEVEMENTS.painting) for (let [key, achievement] of this.map.entries()) {
this.createAndSet(ACHIEVEMENTS.cutting) if (!achievement.isRelevant()) {
this.createAndSet(ACHIEVEMENTS.rotating) this.remove(key);
this.createAndSet(ACHIEVEMENTS.stacking) continue;
this.createAndSet(ACHIEVEMENTS.blueprints, this.isBlueprintsValid); }
if (this.isWiresRelevant()) { if (achievement.signal) {
this.createAndSet(ACHIEVEMENTS.wires, this.isWiresValid, "storyGoalCompleted"); achievement.receiver = this.unlock.bind(this, key);
this.root.signals[achievement.signal].add(achievement.receiver);
}
} }
this.createAndSet(ACHIEVEMENTS.storage, this.isStorageValid, "entityGotNewComponent"); if (!this.hasDefaultReceivers()) {
this.root.signals.achievementUnlocked.remove(this.unlock);
if (this.isFreedomRelevant()) { // ...is it?
this.createAndSet(ACHIEVEMENTS.freedom, this.isFreedomValid, "storyGoalCompleted");
} }
this.createAndSet(ACHIEVEMENTS.networked, this.isNetworkedValid); this.initialized = true;
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 * @param {string} key - Maps to an Achievement
* @param {function} [isValid] - Validates achievement when a signal message is received * @param {object} [options]
* @param {string} [signal] - Signal name to listen to for unlock attempts * @param {function} [options.isValid]
* @param {function} [options.isRelevant]
* @param {string} [options.signal]
*/ */
createAndSet(key, isValid, signal) { createAndSet(key, options) {
const achievement = new Achievement(key); const achievement = new Achievement(key);
achievement.activate = this.activate; achievement.activate = this.activate;
if (isValid) { if (options) {
achievement.isValid = isValid.bind(this); if (options.isValid) {
} achievement.isValid = options.isValid.bind(this);
}
if (signal) { if (options.isRelevant) {
achievement.signal = signal; achievement.isRelevant = options.isRelevant.bind(this);
achievement.receiver = this.unlock.bind(this, key); }
this.root.signals[achievement.signal].add(achievement.receiver);
if (options.signal) {
achievement.signal = options.signal;
}
} }
this.map.set(key, achievement); this.map.set(key, achievement);
@ -165,39 +227,42 @@ export class AchievementCollection {
*/ */
unlock(key) { unlock(key) {
if (!this.map.has(key)) { if (!this.map.has(key)) {
console.log("Achievement unlocked or irrelevant:", key);
return; return;
} }
const achievement = this.map.get(key); const achievement = this.map.get(key);
if (!achievement.isValid(...arguments)) { if (!achievement.isValid(...arguments)) {
console.log("Achievement is invalid:", key);
return; return;
} }
return achievement.unlock() return achievement.unlock()
.finally(() => { .finally(() => {
if (achievement.receiver) { this.remove(key);
this.root.signals[achievement.signal].remove(achievement.receiver);
console.log("Achievement receiver removed:", key);
}
this.map.delete(key);
if (!this.hasDefaultReceivers()) { if (!this.hasDefaultReceivers()) {
this.root.signals.achievementUnlocked.remove(this.unlock); 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() { hasDefaultReceivers() {
if (!this.map.size) { if (!this.map.size) {
return false; return false;
} }
for(let achievement of this.map.values()) { for (let achievement of this.map.values()) {
if (!achievement.signal) { if (!achievement.signal) {
return true; return true;
} }
@ -303,4 +368,32 @@ export class AchievementCollection {
isHundredShapesValid(key) { isHundredShapesValid(key) {
return Object.keys(this.root.hubGoals.storedShapes).length === ONE_HUNDRED; 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(); 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 { NoAdProvider } from "../ad_providers/no_ad_provider";
import { SteamAchievementProvider } from "../electron/steam_achievement_provider"; import { SteamAchievementProvider } from "../electron/steam_achievement_provider";
import { PlatformWrapperInterface } from "../wrapper"; import { PlatformWrapperInterface } from "../wrapper";
import { NoAchievementProvider } from "./no_achievement_provider";
import { StorageImplBrowser } from "./storage"; import { StorageImplBrowser } from "./storage";
import { StorageImplBrowserIndexedDB } from "./storage_indexed_db"; import { StorageImplBrowserIndexedDB } from "./storage_indexed_db";
@ -70,13 +71,9 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface {
logger.log("Embed provider:", this.embedProvider.id); 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() return this.detectStorageImplementation()
.then(() => this.initializeAdProvider()) .then(() => this.initializeAdProvider())
.then(() => this.initializeAchievementProvider())
.then(() => super.initialize()); .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() { exitApp() {
// Can not exit app // Can not exit app
} }

View File

@ -1,6 +1,6 @@
/* typehints:start */ /* typehints:start */
import { Application } from "../../application"; import { Application } from "../../application";
import { Achievement } from "../achievement_provider"; import { GameRoot } from "../../game/root";
/* typehints:end */ /* typehints:end */
import { createLogger } from "../../core/logging"; import { createLogger } from "../../core/logging";
@ -14,20 +14,22 @@ import {
const logger = createLogger("achievements/steam"); const logger = createLogger("achievements/steam");
const ACHIEVEMENT_IDS = { const ACHIEVEMENT_IDS = {
[ACHIEVEMENTS.painting]: "<id>", [ACHIEVEMENTS.blueprints]: "<id>",
[ACHIEVEMENTS.cutting]: "achievement_01", // Test 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.rotating]: "<id>",
[ACHIEVEMENTS.stacking]: "<id>", [ACHIEVEMENTS.stacking]: "<id>",
[ACHIEVEMENTS.blueprints]: "<id>",
[ACHIEVEMENTS.wires]: "<id>",
[ACHIEVEMENTS.storage]: "<id>", [ACHIEVEMENTS.storage]: "<id>",
[ACHIEVEMENTS.freedom]: "<id>",
[ACHIEVEMENTS.networked]: "<id>",
[ACHIEVEMENTS.theLogo]: "<id>", [ACHIEVEMENTS.theLogo]: "<id>",
[ACHIEVEMENTS.toTheMoon]: "<id>", [ACHIEVEMENTS.toTheMoon]: "<id>",
[ACHIEVEMENTS.millionBlueprintShapes]: "<id>", [ACHIEVEMENTS.wires]: "<id>",
[ACHIEVEMENTS.hundredShapes]: "<id>",
}; };
export class SteamAchievementProvider extends AchievementProviderInterface { export class SteamAchievementProvider extends AchievementProviderInterface {
@ -36,10 +38,9 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
super(app); super(app);
this.initialized = false; this.initialized = false;
this.keys = Object.keys(ACHIEVEMENT_IDS); this.collection = new AchievementCollection(this.activate.bind(this));
this.collection = new AchievementCollection(this.keys, 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; return true;
} }
initialize (root) { /** @param {GameRoot} root */
this.collection.initialize(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) { if (!G_IS_STANDALONE) {
logger.warn("Steam unavailable. Achievements won't sync."); logger.warn("Steam unavailable. Achievements won't sync.");
return Promise.resolve(); return Promise.resolve();
@ -68,11 +83,7 @@ export class SteamAchievementProvider extends AchievementProviderInterface {
} else { } else {
logger.log("Steam achievement provider initialized"); 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 return promise
.then(() => { .then(() => {
logger.log("Achievement unlocked:", key); logger.log("Achievement activated:", key);
}) })
.catch(err => { .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 { PlatformWrapperImplBrowser } from "../browser/wrapper";
import { getIPCRenderer } from "../../core/utils"; import { getIPCRenderer } from "../../core/utils";
import { createLogger } from "../../core/logging"; import { createLogger } from "../../core/logging";
@ -22,7 +23,8 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
this.app.storage = new StorageImplElectron(this); this.app.storage = new StorageImplElectron(this);
this.app.achievementProvider = new SteamAchievementProvider(this.app); this.app.achievementProvider = new SteamAchievementProvider(this.app);
return PlatformWrapperInterface.prototype.initialize.call(this); return this.initializeAchievementProvider()
.then(() => PlatformWrapperInterface.prototype.initialize.call(this));
} }
steamOverlayFixRedrawCanvas() { steamOverlayFixRedrawCanvas() {
@ -55,6 +57,15 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
return Promise.resolve(); 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() { getSupportsFullscreen() {
return true; return true;
} }