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

Add more achievements, refactor achievement code

This commit is contained in:
Greg Considine 2021-03-01 18:07:21 -05:00
parent dab41d19e7
commit 9597f3e89c
18 changed files with 338 additions and 248 deletions

View File

@ -12,7 +12,7 @@ import { getPlatformName, waitNextFrame } from "./core/utils";
import { Vector } from "./core/vector";
import { AdProviderInterface } from "./platform/ad_provider";
import { NoAdProvider } from "./platform/ad_providers/no_ad_provider";
import { NoAchievements } from "./platform/browser/no_achievements";
import { NoAchievementProvider } from "./platform/browser/no_achievement_provider";
import { AnalyticsInterface } from "./platform/analytics";
import { GoogleAnalyticsImpl } from "./platform/browser/google_analytics";
import { SoundImplBrowser } from "./platform/browser/sound";
@ -33,7 +33,7 @@ import { ShapezGameAnalytics } from "./platform/browser/game_analytics";
import { RestrictionManager } from "./core/restriction_manager";
/**
* @typedef {import("./platform/achievements").AchievementsInterface} AchievementsInterface
* @typedef {import("./platform/achievement_provider").AchievementProviderInterface} AchievementProviderInterface
* @typedef {import("./platform/game_analytics").GameAnalyticsInterface} GameAnalyticsInterface
* @typedef {import("./platform/sound").SoundInterface} SoundInterface
* @typedef {import("./platform/storage").StorageInterface} StorageInterface
@ -87,8 +87,8 @@ export class Application {
/** @type {PlatformWrapperInterface} */
this.platformWrapper = null;
/** @type {AchievementsInterface} */
this.achievements = null;
/** @type {AchievementProviderInterface} */
this.achievementProvider = null;
/** @type {AdProviderInterface} */
this.adProvider = null;
@ -142,7 +142,7 @@ export class Application {
this.sound = new SoundImplBrowser(this);
this.analytics = new GoogleAnalyticsImpl(this);
this.gameAnalytics = new ShapezGameAnalytics(this);
this.achievements = new NoAchievements(this);
this.achievementProvider = new NoAchievementProvider(this);
}
/**

View File

@ -1,28 +0,0 @@
/* typehints:start */
import { GameRoot } from "./root";
/* typehints:end */
import { globalConfig } from "../core/config";
import { createLogger } from "../core/logging";
const logger = createLogger("achievement_manager");
export class AchievementManager {
constructor(root) {
this.root = root;
this.achievements = this.root.app.achievements;
if (!this.achievements.hasAchievements()) {
logger.log("Achievements disabled");
return;
}
logger.log("There are", this.achievements.count, "achievements");
this.root.signals.achievementUnlocked.add(this.unlock, this);
}
unlock (key) {
this.achievements.unlock(key);
}
}

View File

@ -0,0 +1,29 @@
/* typehints:start */
import { GameRoot } from "./root";
/* typehints:end */
import { createLogger } from "../core/logging";
const logger = createLogger("achievement_proxy");
export class AchievementProxy {
/** @param {GameRoot} root */
constructor(root) {
this.root = root;
this.provider = this.root.app.achievementProvider;
if (!this.provider.hasAchievements()) {
return;
}
this.provider.initialize()
.then(() => {
this.root.signals.achievementUnlocked.add(this.provider.unlock, this.provider);
logger.log("Listening for unlocked achievements");
})
.catch(err => {
logger.error("Ignoring achievement signals", err);
})
}
}

View File

@ -3,7 +3,7 @@ import { DrawParameters } from "../core/draw_parameters";
import { findNiceIntegerValue } from "../core/utils";
import { Vector } from "../core/vector";
import { Entity } from "./entity";
import { ACHIEVEMENTS } from "../platform/achievements";
import { ACHIEVEMENTS } from "../platform/achievement_provider";
import { GameRoot } from "./root";
export class Blueprint {

View File

@ -35,7 +35,7 @@ import { RegularGameMode } from "./modes/regular";
import { ProductionAnalytics } from "./production_analytics";
import { GameRoot } from "./root";
import { ShapeDefinitionManager } from "./shape_definition_manager";
import { AchievementManager } from "./achievement_manager";
import { AchievementProxy } from "./achievement_proxy";
import { SoundProxy } from "./sound_proxy";
import { GameTime } from "./time/game_time";
@ -112,6 +112,7 @@ export class GameCore {
root.logic = new GameLogic(root);
root.hud = new GameHUD(root);
root.time = new GameTime(root);
root.achievementProxy = new AchievementProxy(root);
root.automaticSave = new AutomaticSave(root);
root.soundProxy = new SoundProxy(root);
@ -119,7 +120,6 @@ export class GameCore {
root.entityMgr = new EntityManager(root);
root.systemMgr = new GameSystemManager(root);
root.shapeDefinitionMgr = new ShapeDefinitionManager(root);
root.achievementMgr = new AchievementManager(root);
root.hubGoals = new HubGoals(root);
root.productionAnalytics = new ProductionAnalytics(root);
root.buffers = new BufferMaintainer(root);

View File

@ -1,6 +1,7 @@
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";
@ -260,6 +261,12 @@ 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

@ -8,7 +8,7 @@ import { createLogger } from "../core/logging";
import { GameTime } from "./time/game_time";
import { EntityManager } from "./entity_manager";
import { GameSystemManager } from "./game_system_manager";
import { AchievementManager } from "./achievement_manager";
import { AchievementProxy } from "./achievement_proxy";
import { GameHUD } from "./hud/hud";
import { MapView } from "./map_view";
import { Camera } from "./camera";
@ -120,8 +120,8 @@ export class GameRoot {
/** @type {SoundProxy} */
this.soundProxy = null;
/** @type {AchievementManager} */
this.achievementMgr = null;
/** @type {AchievementProxy} */
this.achievementProxy = null;
/** @type {ShapeDefinitionManager} */
this.shapeDefinitionMgr = null;

View File

@ -4,7 +4,7 @@ import { enumColors } from "./colors";
import { ShapeItem } from "./items/shape_item";
import { GameRoot } from "./root";
import { enumSubShape, ShapeDefinition } from "./shape_definition";
import { ACHIEVEMENTS } from "../platform/achievements";
import { ACHIEVEMENTS } from "../platform/achievement_provider";
const logger = createLogger("shape_definition_manager");
@ -251,6 +251,9 @@ export class ShapeDefinitionManager extends BasicSerializableObject {
return this.shapeKeyToDefinition[id];
}
this.shapeKeyToDefinition[id] = definition;
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.theLogo, definition);
// logger.log("Registered shape with key (2)", id);
return definition;
}

View File

@ -4,6 +4,7 @@ import { createLogger } from "../../core/logging";
import { Rectangle } from "../../core/rectangle";
import { StaleAreaDetector } from "../../core/stale_area_detector";
import { enumDirection, enumDirectionToVector } from "../../core/vector";
import { ACHIEVEMENTS } from "../../platform/achievement_provider";
import { BaseItem } from "../base_item";
import { BeltComponent } from "../components/belt";
import { ItemAcceptorComponent } from "../components/item_acceptor";
@ -274,6 +275,11 @@ export class ItemEjectorSystem extends GameSystemWithFilter {
// It's a storage
if (storageComp.canAcceptItem(item)) {
storageComp.takeItem(item);
if (storageComp.storedCount === 1) {
this.root.signals.achievementUnlocked.dispatch(ACHIEVEMENTS.storage);
}
return true;
}

View File

@ -13,6 +13,7 @@ import {
enumInvertedDirections,
Vector,
} from "../../core/vector";
import { ACHIEVEMENTS } from "../../platform/achievement_provider";
import { BaseItem } from "../base_item";
import { arrayWireRotationVariantToType, MetaWireBuilding } from "../buildings/wire";
import { getCodeFromBuildingData } from "../building_codes";
@ -697,6 +698,12 @@ 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);
}
// Invalidate affected area
const originalRect = staticComp.getTileSpaceBounds();
const affectedArea = originalRect.expandedInAllDirections(1);

View File

@ -0,0 +1,138 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
export const ACHIEVEMENTS = {
painting: "painting",
cutting: "cutting",
rotating: "rotating",
stacking: "stacking",
blueprints: "blueprints",
wires: "wires",
storage: "storage",
freedom: "freedom",
networked: "networked",
theLogo: "theLogo",
};
export class AchievementProviderInterface {
/** @param {Application} app */
constructor(app) {
this.app = app;
}
/**
* Initializes the achievement provider.
* @returns {Promise<void>}
*/
initialize() {
abstract;
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}
*/
hasAchievements() {
abstract;
return false;
}
}
export class Achievement {
/**
* @param {string} key - An ACHIEVEMENTS key
*/
constructor (key) {
this.key = key;
this.unlock = null;
this.isValid = null;
}
}
export class AchievementCollection {
/**
* @param {string[]} keys - An array of ACHIEVEMENTS keys
* @param {function} [activate] - Resolves when provider activation is complete
*/
constructor (keys, activate) {
this.map = new Map();
this.activate = activate ? activate : () => Promise.resolve();
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 {string} key - Maps to an Achievement
* @returns {boolean}
*/
has(key) {
return this.map.has(key);
}
/**
* @param {string} key - Maps to an Achievement
* @param {*} [details] - Additional information as needed to validate
* @returns {boolean}
*/
isValid(key, details) {
return this.map.get(key).isValid(details);
}
/**
* @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;
}
}
/**
* @param {string} shortKey - The shape's shortKey to check
* @returns {boolean}
*/
isTheLogoValid(shortKey) {
return shortKey === "RuCw--Cw:----Ru--";
}
}

View File

@ -1,58 +0,0 @@
/* typehints:start */
import { Application } from "../application";
/* typehints:end */
export const ACHIEVEMENTS = {
painting: "painting",
cutting: "cutting",
rotating: "rotating",
stacking: "stacking",
blueprints: "blueprints",
}
export class AchievementsInterface {
/** @param {Application} app */
constructor(app) {
this.app = app;
}
/**
* Load achievements into an initial state, bypassing unlocked and/or
* irrelevant achievements where possible.
*
* @params key
* @returns {Promise<void>}
*/
load() {
abstract;
return Promise.reject();
}
/**
* Call to unlock an achievement
* @params {string} [key] - A property within the ACHIEVEMENTS enum or empty if
* bypassing.
* @returns {void}
*/
unlock(key) {
abstract;
}
/**
* Initializes the list of achievements.
* @returns {Promise<void>}
*/
initialize() {
abstract;
return Promise.reject();
}
/**
* Checks if achievements are supported in the current build
* @returns {boolean}
*/
hasAchievements() {
abstract;
return false;
}
}

View File

@ -0,0 +1,14 @@
import { AchievementProviderInterface } from "../achievement_provider";
export class NoAchievementProvider extends AchievementProviderInterface {
hasAchievements() {
return false;
}
initialize() {
return Promise.resolve();
}
unlock() {
}
}

View File

@ -1,14 +0,0 @@
import { AchievementsInterface } from "../achievements";
export class NoAchievements extends AchievementsInterface {
load() {
return Promise.resolve();
}
hasAchievements() {
return false;
}
unlock() {
}
}

View File

@ -4,7 +4,7 @@ import { queryParamOptions } from "../../core/query_parameters";
import { clamp } from "../../core/utils";
import { GamedistributionAdProvider } from "../ad_providers/gamedistribution";
import { NoAdProvider } from "../ad_providers/no_ad_provider";
import { SteamAchievements } from "../electron/steam_achievements";
import { SteamAchievementProvider } from "../electron/steam_achievement_provider";
import { PlatformWrapperInterface } from "../wrapper";
import { StorageImplBrowser } from "./storage";
import { StorageImplBrowserIndexedDB } from "./storage_indexed_db";
@ -72,7 +72,7 @@ export class PlatformWrapperImplBrowser extends PlatformWrapperInterface {
if (G_IS_DEV && globalConfig.debug.testAchievements) {
logger.log("Testing achievements");
this.app.achievements = new SteamAchievements(this.app);
this.app.achievementProvider = new SteamAchievementProvider(this.app);
}
return this.detectStorageImplementation()

View File

@ -0,0 +1,118 @@
/* typehints:start */
import { Application } from "../../application";
import { Achievement } from "../achievement_provider";
/* typehints:end */
import { createLogger } from "../../core/logging";
import { getIPCRenderer } from "../../core/utils";
import {
ACHIEVEMENTS,
AchievementCollection,
AchievementProviderInterface
} from "../achievement_provider";
const logger = createLogger("achievements/steam");
const ACHIEVEMENT_IDS = {
[ACHIEVEMENTS.painting]: "<id>",
[ACHIEVEMENTS.cutting]: "achievement_01", // Test 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>"
};
export class SteamAchievementProvider extends AchievementProviderInterface {
/** @param {Application} app */
constructor(app) {
super(app);
this.initialized = false;
this.collection = new AchievementCollection(
Object.keys(ACHIEVEMENT_IDS),
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;
}
}

View File

@ -1,132 +0,0 @@
import { globalConfig } from "../../core/config";
import { createLogger } from "../../core/logging";
import { getIPCRenderer } from "../../core/utils";
import { ACHIEVEMENTS, AchievementsInterface } from "../achievements";
const logger = createLogger("achievements/steam");
const IDS = {
painting: "<id>",
cutting: "achievement_01", // Test ID
rotating: "<id>",
stacking: "<id>",
blueprints: "<id>",
wires: "<id>",
}
/** @typedef {object} SteamAchievement
* @property {string} id
* @property {string} key
* @property {boolean} unlocked
* @property {boolean} relevant
* @property {?Promise} activate
*/
/** @typedef {Map<string, SteamAchievement>} SteamAchievementMap */
export class SteamAchievements extends AchievementsInterface {
constructor(app) {
super(app);
/** @type {SteamAchievementMap} */
this.map = new Map();
this.type = "Steam";
this.count = 0;
this.steamInitialized = false;
logger.log("Initializing", this.type, "achievements");
for (let key in ACHIEVEMENTS) {
this.map.set(key, {
id: IDS[key],
key: key,
unlocked: false,
relevant: true,
activate: null
});
this.count++;
}
this.load()
}
load () {
// TODO: inspect save file and update achievements
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;
}
logger.log("Steam listener is active");
this.steamInitialized = true;
return this.ipc.invoke("steam:get-achievement-names")
.then(result => {
logger.log("steam:get-achievement-names", result);
});
})
.catch(err => {
logger.error(err);
})
}
/**
* @param {string} key
*/
unlock (key) {
if (!this.map.has(key)) {
logger.warn("Achievement does not exist:", key);
return;
}
let achievement = this.map.get(key);
if (!achievement.relevant) {
console.log("Achievement unlocked/irrelevant:", key);
return;
}
achievement.activate = achievement.activate || this.activate(achievement)
.then(() => {
achievement.unlocked = true;
achievement.relevant = false;
logger.log("Achievement unlocked:", key);
})
.catch(err => {
logger.error("Failed to unlock achievement", err);
})
.finally(() => {
achievement.activate = null;
})
}
/**
* @param {SteamAchievement} achievement
*/
activate (achievement) {
if (!this.steamInitialized) {
return Promise.resolve();
}
return this.ipc.invoke("steam:activate-achievement", achievement.id)
}
hasAchievements() {
return true;
}
}

View File

@ -2,7 +2,7 @@ import { PlatformWrapperImplBrowser } from "../browser/wrapper";
import { getIPCRenderer } from "../../core/utils";
import { createLogger } from "../../core/logging";
import { StorageImplElectron } from "./storage";
import { SteamAchievements } from "./steam_achievements";
import { SteamAchievementProvider } from "./steam_achievement_provider";
import { PlatformWrapperInterface } from "../wrapper";
const logger = createLogger("electron-wrapper");
@ -20,7 +20,7 @@ export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser {
this.app.ticker.frameEmitted.add(this.steamOverlayFixRedrawCanvas, this);
this.app.storage = new StorageImplElectron(this);
this.app.achievements = new SteamAchievements(this.app);
this.app.achievementProvider = new SteamAchievementProvider(this.app);
return PlatformWrapperInterface.prototype.initialize.call(this);
}