diff --git a/src/css/ingame_hud/game_menu.scss b/src/css/ingame_hud/game_menu.scss index 0971bd29..816b97f4 100644 --- a/src/css/ingame_hud/game_menu.scss +++ b/src/css/ingame_hud/game_menu.scss @@ -30,7 +30,7 @@ transform: scale(0.9) !important; } - opacity: 0.5; + opacity: 0.7; &:hover { opacity: 0.9 !important; } diff --git a/src/js/game/buildings/filter.js b/src/js/game/buildings/filter.js index fb368fc7..37ad234a 100644 --- a/src/js/game/buildings/filter.js +++ b/src/js/game/buildings/filter.js @@ -1,11 +1,7 @@ import { enumDirection, Vector } from "../../core/vector"; +import { FilterComponent } from "../components/filter"; import { ItemAcceptorComponent } from "../components/item_acceptor"; import { ItemEjectorComponent } from "../components/item_ejector"; -import { - enumItemProcessorRequirements, - enumItemProcessorTypes, - ItemProcessorComponent, -} from "../components/item_processor"; import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; import { Entity } from "../entity"; import { MetaBuilding } from "../meta_building"; @@ -79,12 +75,6 @@ export class MetaFilterBuilding extends MetaBuilding { }) ); - entity.addComponent( - new ItemProcessorComponent({ - processorType: enumItemProcessorTypes.filter, - inputsPerCharge: 1, - processingRequirement: enumItemProcessorRequirements.filter, - }) - ); + entity.addComponent(new FilterComponent()); } } diff --git a/src/js/game/component_registry.js b/src/js/game/component_registry.js index b03c164f..c4ea6921 100644 --- a/src/js/game/component_registry.js +++ b/src/js/game/component_registry.js @@ -17,6 +17,7 @@ import { LeverComponent } from "./components/lever"; import { WireTunnelComponent } from "./components/wire_tunnel"; import { DisplayComponent } from "./components/display"; import { BeltReaderComponent } from "./components/belt_reader"; +import { FilterComponent } from "./components/filter"; export function initComponentRegistry() { gComponentRegistry.register(StaticMapEntityComponent); @@ -37,6 +38,7 @@ export function initComponentRegistry() { gComponentRegistry.register(WireTunnelComponent); gComponentRegistry.register(DisplayComponent); gComponentRegistry.register(BeltReaderComponent); + gComponentRegistry.register(FilterComponent); // IMPORTANT ^^^^^ UPDATE ENTITY COMPONENT STORAGE AFTERWARDS diff --git a/src/js/game/components/filter.js b/src/js/game/components/filter.js new file mode 100644 index 00000000..cffee969 --- /dev/null +++ b/src/js/game/components/filter.js @@ -0,0 +1,55 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { typeItemSingleton } from "../item_resolver"; + +/** + * @typedef {{ + * item: BaseItem, + * progress: number + * }} PendingFilterItem + */ + +export class FilterComponent extends Component { + static getId() { + return "Filter"; + } + + duplicateWithoutContents() { + return new FilterComponent(); + } + + static getSchema() { + return { + pendingItemsToLeaveThrough: types.array( + types.structured({ + item: typeItemSingleton, + progress: types.ufloat, + }) + ), + + pendingItemsToReject: types.array( + types.structured({ + item: typeItemSingleton, + progress: types.ufloat, + }) + ), + }; + } + + constructor() { + super(); + + /** + * Items in queue to leave through + * @type {Array} + */ + this.pendingItemsToLeaveThrough = []; + + /** + * Items in queue to reject + * @type {Array} + */ + this.pendingItemsToReject = []; + } +} diff --git a/src/js/game/components/item_processor.js b/src/js/game/components/item_processor.js index 4947a521..acc65192 100644 --- a/src/js/game/components/item_processor.js +++ b/src/js/game/components/item_processor.js @@ -24,7 +24,6 @@ export const enumItemProcessorTypes = { /** @enum {string} */ export const enumItemProcessorRequirements = { painterQuad: "painterQuad", - filter: "filter", }; /** @typedef {{ diff --git a/src/js/game/entity_components.js b/src/js/game/entity_components.js index 4a2241e3..32870d37 100644 --- a/src/js/game/entity_components.js +++ b/src/js/game/entity_components.js @@ -17,6 +17,7 @@ import { LeverComponent } from "./components/lever"; import { WireTunnelComponent } from "./components/wire_tunnel"; import { DisplayComponent } from "./components/display"; import { BeltReaderComponent } from "./components/belt_reader"; +import { FilterComponent } from "./components/filter"; /* typehints:end */ /** @@ -81,6 +82,9 @@ export class EntityComponentStorage { /** @type {BeltReaderComponent} */ this.BeltReader; + /** @type {FilterComponent} */ + this.Filter; + /* typehints:end */ } } diff --git a/src/js/game/game_system_manager.js b/src/js/game/game_system_manager.js index b0ae46f2..2c81f899 100644 --- a/src/js/game/game_system_manager.js +++ b/src/js/game/game_system_manager.js @@ -22,6 +22,7 @@ import { LeverSystem } from "./systems/lever"; import { DisplaySystem } from "./systems/display"; import { ItemProcessorOverlaysSystem } from "./systems/item_processor_overlays"; import { BeltReaderSystem } from "./systems/belt_reader"; +import { FilterSystem } from "./systems/filter"; const logger = createLogger("game_system_manager"); @@ -92,6 +93,9 @@ export class GameSystemManager { /** @type {BeltReaderSystem} */ beltReader: null, + /** @type {FilterSystem} */ + filter: null, + /* typehints:end */ }; this.systemUpdateOrder = []; @@ -124,6 +128,8 @@ export class GameSystemManager { add("itemProcessor", ItemProcessorSystem); + add("filter", FilterSystem); + add("itemEjector", ItemEjectorSystem); add("mapResources", MapResourcesSystem); diff --git a/src/js/game/hub_goals.js b/src/js/game/hub_goals.js index f0cd0002..c2f17f8f 100644 --- a/src/js/game/hub_goals.js +++ b/src/js/game/hub_goals.js @@ -401,7 +401,6 @@ export class HubGoals extends BasicSerializableObject { return 1e30; case enumItemProcessorTypes.splitter: return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt * 2; - case enumItemProcessorTypes.filter: case enumItemProcessorTypes.reader: return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt; diff --git a/src/js/game/systems/display.js b/src/js/game/systems/display.js index 2ad551f0..f11091b9 100644 --- a/src/js/game/systems/display.js +++ b/src/js/game/systems/display.js @@ -65,7 +65,7 @@ export class DisplaySystem extends GameSystemWithFilter { const pinsComp = entity.components.WiredPins; const network = pinsComp.slots[0].linkedNetwork; - if (!network || !network.currentValue) { + if (!network || !network.hasValue()) { continue; } diff --git a/src/js/game/systems/filter.js b/src/js/game/systems/filter.js new file mode 100644 index 00000000..a6442b41 --- /dev/null +++ b/src/js/game/systems/filter.js @@ -0,0 +1,85 @@ +import { globalConfig } from "../../core/config"; +import { BaseItem } from "../base_item"; +import { FilterComponent } from "../components/filter"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BOOL_TRUE_SINGLETON } from "../items/boolean_item"; + +const MAX_ITEMS_IN_QUEUE = 2; + +export class FilterSystem extends GameSystemWithFilter { + constructor(root) { + super(root, [FilterComponent]); + } + + update() { + const progress = + this.root.dynamicTickrate.deltaSeconds * + this.root.hubGoals.getBeltBaseSpeed() * + globalConfig.itemSpacingOnBelts; + + const requiredProgress = 1 - progress; + + for (let i = 0; i < this.allEntities.length; ++i) { + const entity = this.allEntities[i]; + const filterComp = entity.components.Filter; + const ejectorComp = entity.components.ItemEjector; + + // Process payloads + const slotsAndLists = [filterComp.pendingItemsToLeaveThrough, filterComp.pendingItemsToReject]; + for (let slotIndex = 0; slotIndex < slotsAndLists.length; ++slotIndex) { + const pendingItems = slotsAndLists[slotIndex]; + + for (let j = 0; j < pendingItems.length; ++j) { + const nextItem = pendingItems[j]; + // Advance next item + nextItem.progress = Math.min(requiredProgress, nextItem.progress + progress); + // Check if it's ready to eject + if (nextItem.progress >= requiredProgress - 1e-5) { + if (ejectorComp.tryEject(slotIndex, nextItem.item)) { + pendingItems.shift(); + } + } + } + } + } + } + + /** + * + * @param {Entity} entity + * @param {number} slot + * @param {BaseItem} item + */ + tryAcceptItem(entity, slot, item) { + const network = entity.components.WiredPins.slots[0].linkedNetwork; + if (!network || !network.hasValue()) { + // Filter is not connected + return false; + } + + const value = network.currentValue; + const filterComp = entity.components.Filter; + assert(filterComp, "entity is no filter"); + + // Figure out which list we have to check + let listToCheck; + if (value.equals(BOOL_TRUE_SINGLETON) || value.equals(item)) { + listToCheck = filterComp.pendingItemsToLeaveThrough; + } else { + listToCheck = filterComp.pendingItemsToReject; + } + + if (listToCheck.length >= MAX_ITEMS_IN_QUEUE) { + // Busy + return false; + } + + // Actually accept item + listToCheck.push({ + item, + progress: 0.0, + }); + return true; + } +} diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index 3bd97cc0..4f7d29be 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -282,6 +282,15 @@ export class ItemEjectorSystem extends GameSystemWithFilter { return false; } + const filterComp = receiver.components.Filter; + if (filterComp) { + // It's a filter! Unfortunately the filter has to know a lot about it's + // surrounding state and components, so it can't be within the component itself. + if (this.root.systemMgr.systems.filter.tryAcceptItem(receiver, slotIndex, item)) { + return true; + } + } + return false; } diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js index 8da414f2..b4a7231b 100644 --- a/src/js/game/systems/item_processor.js +++ b/src/js/game/systems/item_processor.js @@ -58,7 +58,6 @@ export class ItemProcessorSystem extends GameSystemWithFilter { [enumItemProcessorTypes.painterDouble]: this.process_PAINTER_DOUBLE, [enumItemProcessorTypes.painterQuad]: this.process_PAINTER_QUAD, [enumItemProcessorTypes.hub]: this.process_HUB, - [enumItemProcessorTypes.filter]: this.process_FILTER, [enumItemProcessorTypes.reader]: this.process_READER, }; @@ -162,24 +161,13 @@ export class ItemProcessorSystem extends GameSystemWithFilter { // Check the network value at the given slot const network = pinsComp.slots[slotIndex - 1].linkedNetwork; - const slotIsEnabled = network && isTruthyItem(network.currentValue); + const slotIsEnabled = network && network.hasValue() && isTruthyItem(network.currentValue); if (!slotIsEnabled) { return false; } return true; } - case enumItemProcessorRequirements.filter: { - const network = pinsComp.slots[0].linkedNetwork; - if (!network || !network.currentValue) { - // Item filter is not connected - return false; - } - - // Otherwise, all good - return true; - } - // By default, everything is accepted default: return true; @@ -222,9 +210,8 @@ export class ItemProcessorSystem extends GameSystemWithFilter { // Check which slots are enabled for (let i = 0; i < 4; ++i) { // Extract the network value on the Nth pin - const networkValue = pinsComp.slots[i].linkedNetwork - ? pinsComp.slots[i].linkedNetwork.currentValue - : null; + const network = pinsComp.slots[i].linkedNetwork; + const networkValue = network && network.hasValue() ? network.currentValue : null; // If there is no "1" on that slot, don't paint there if (!isTruthyItem(networkValue)) { @@ -257,18 +244,6 @@ export class ItemProcessorSystem extends GameSystemWithFilter { return true; } - // FILTER - // Double check with linked network - case enumItemProcessorRequirements.filter: { - const network = entity.components.WiredPins.slots[0].linkedNetwork; - if (!network || !network.currentValue) { - // Item filter is not connected - return false; - } - - return processorComp.inputSlots.length >= processorComp.inputsPerCharge; - } - default: assertAlways(false, "Unknown requirement for " + processorComp.processingRequirement); } @@ -553,38 +528,6 @@ export class ItemProcessorSystem extends GameSystemWithFilter { }); } - /** - * @param {ProcessorImplementationPayload} payload - */ - process_FILTER(payload) { - const item = payload.itemsBySlot[0]; - - const network = payload.entity.components.WiredPins.slots[0].linkedNetwork; - if (!network || !network.currentValue) { - payload.outItems.push({ - item, - requiredSlot: 1, - doNotTrack: true, - }); - return; - } - - const value = network.currentValue; - if (value.equals(BOOL_TRUE_SINGLETON) || value.equals(item)) { - payload.outItems.push({ - item, - requiredSlot: 0, - doNotTrack: true, - }); - } else { - payload.outItems.push({ - item, - requiredSlot: 1, - doNotTrack: true, - }); - } - } - /** * @param {ProcessorImplementationPayload} payload */ diff --git a/src/js/game/systems/item_processor_overlays.js b/src/js/game/systems/item_processor_overlays.js index 26fc14dc..3ba44c7b 100644 --- a/src/js/game/systems/item_processor_overlays.js +++ b/src/js/game/systems/item_processor_overlays.js @@ -34,34 +34,40 @@ export class ItemProcessorOverlaysSystem extends GameSystem { for (let i = 0; i < contents.length; ++i) { const entity = contents[i]; const processorComp = entity.components.ItemProcessor; - if (!processorComp) { - continue; - } + const filterComp = entity.components.Filter; - const requirement = processorComp.processingRequirement; - if (!requirement && processorComp.type !== enumItemProcessorTypes.reader) { - continue; - } - - if (this.drawnUids.has(entity.uid)) { - continue; - } - - this.drawnUids.add(entity.uid); - - switch (requirement) { - case enumItemProcessorRequirements.painterQuad: { - this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: true }); - break; + // Draw processor overlays + if (processorComp) { + const requirement = processorComp.processingRequirement; + if (!requirement && processorComp.type !== enumItemProcessorTypes.reader) { + continue; } - case enumItemProcessorRequirements.filter: { - this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: false }); - break; + + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + + switch (requirement) { + case enumItemProcessorRequirements.painterQuad: { + this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: true }); + break; + } + } + + if (processorComp.type === enumItemProcessorTypes.reader) { + this.drawReaderOverlays(parameters, entity); } } - if (processorComp.type === enumItemProcessorTypes.reader) { - this.drawReaderOverlays(parameters, entity); + // Draw filter overlays + else if (filterComp) { + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + + this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: false }); } } } @@ -111,7 +117,7 @@ export class ItemProcessorOverlaysSystem extends GameSystem { for (let i = 0; i < pinsComp.slots.length; ++i) { const slot = pinsComp.slots[i]; const network = slot.linkedNetwork; - if (network && network.currentValue) { + if (network && network.hasValue()) { anySlotConnected = true; if (isTruthyItem(network.currentValue) || !drawIfFalse) { diff --git a/src/js/game/systems/logic_gate.js b/src/js/game/systems/logic_gate.js index 923bde79..cd2d7dfb 100644 --- a/src/js/game/systems/logic_gate.js +++ b/src/js/game/systems/logic_gate.js @@ -47,13 +47,13 @@ export class LogicGateSystem extends GameSystemWithFilter { if (slot.type !== enumPinSlotType.logicalAcceptor) { continue; } - if (slot.linkedNetwork) { - if (slot.linkedNetwork.valueConflict) { + const network = slot.linkedNetwork; + if (network) { + if (network.valueConflict) { anyConflict = true; break; } - - slotValues.push(slot.linkedNetwork.currentValue); + slotValues.push(network.currentValue); } else { slotValues.push(null); } diff --git a/src/js/game/systems/wire.js b/src/js/game/systems/wire.js index 79f8a780..4168edc4 100644 --- a/src/js/game/systems/wire.js +++ b/src/js/game/systems/wire.js @@ -79,6 +79,14 @@ export class WireNetwork { */ this.uid = ++networkUidCounter; } + + /** + * Returns whether this network currently has a value + * @returns {boolean} + */ + hasValue() { + return !!this.currentValue && !this.valueConflict; + } } export class WireSystem extends GameSystemWithFilter { diff --git a/src/js/platform/browser/game_analytics.js b/src/js/platform/browser/game_analytics.js index 7336ffd9..19a67f57 100644 --- a/src/js/platform/browser/game_analytics.js +++ b/src/js/platform/browser/game_analytics.js @@ -1,261 +1,261 @@ -import { globalConfig } from "../../core/config"; -import { createLogger } from "../../core/logging"; -import { GameRoot } from "../../game/root"; -import { InGameState } from "../../states/ingame"; -import { GameAnalyticsInterface } from "../game_analytics"; -import { FILE_NOT_FOUND } from "../storage"; -import { blueprintShape, UPGRADES } from "../../game/upgrades"; -import { tutorialGoals } from "../../game/tutorial_goals"; -import { BeltComponent } from "../../game/components/belt"; -import { StaticMapEntityComponent } from "../../game/components/static_map_entity"; - -const logger = createLogger("game_analytics"); - -const analyticsUrl = G_IS_DEV ? "http://localhost:8001" : "https://analytics.shapez.io"; - -// Be sure to increment the ID whenever it changes to make sure all -// users are tracked -const analyticsLocalFile = "shapez_token_123.bin"; - -export class ShapezGameAnalytics extends GameAnalyticsInterface { - get environment() { - if (G_IS_DEV) { - return "dev"; - } - - if (G_IS_STANDALONE) { - return "steam"; - } - - if (G_IS_RELEASE) { - return "prod"; - } - - return "beta"; - } - - /** - * @returns {Promise} - */ - initialize() { - this.syncKey = null; - - setInterval(() => this.sendTimePoints(), 60 * 1000); - - // Retrieve sync key from player - return this.app.storage.readFileAsync(analyticsLocalFile).then( - syncKey => { - this.syncKey = syncKey; - logger.log("Player sync key read:", this.syncKey); - }, - error => { - // File was not found, retrieve new key - if (error === FILE_NOT_FOUND) { - logger.log("Retrieving new player key"); - - // Perform call to get a new key from the API - this.sendToApi("/v1/register", { - environment: this.environment, - }) - .then(res => { - // Try to read and parse the key from the api - if (res.key && typeof res.key === "string" && res.key.length === 40) { - this.syncKey = res.key; - logger.log("Key retrieved:", this.syncKey); - this.app.storage.writeFileAsync(analyticsLocalFile, res.key); - } else { - throw new Error("Bad response from analytics server: " + res); - } - }) - .catch(err => { - logger.error("Failed to register on analytics api:", err); - }); - } else { - logger.error("Failed to read ga key:", error); - } - return; - } - ); - } - - /** - * Sends a request to the api - * @param {string} endpoint Endpoint without base url - * @param {object} data payload - * @returns {Promise} - */ - sendToApi(endpoint, data) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject("Request to " + endpoint + " timed out"), 20000); - - fetch(analyticsUrl + endpoint, { - method: "POST", - mode: "cors", - cache: "no-cache", - referrer: "no-referrer", - credentials: "omit", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "x-api-key": globalConfig.info.analyticsApiKey, - }, - body: JSON.stringify(data), - }) - .then(res => { - clearTimeout(timeout); - if (!res.ok || res.status !== 200) { - reject("Fetch error: Bad status " + res.status); - } else { - return res.json(); - } - }) - .then(resolve) - .catch(reason => { - clearTimeout(timeout); - reject(reason); - }); - }); - } - - /** - * Sends a game event to the analytics - * @param {string} category - * @param {string} value - */ - sendGameEvent(category, value) { - if (!this.syncKey) { - logger.warn("Can not send event due to missing sync key"); - return; - } - - const gameState = this.app.stateMgr.currentState; - if (!(gameState instanceof InGameState)) { - logger.warn("Trying to send analytics event outside of ingame state"); - return; - } - - const savegame = gameState.savegame; - if (!savegame) { - logger.warn("Ingame state has empty savegame"); - return; - } - - const savegameId = savegame.internalId; - if (!gameState.core) { - logger.warn("Game state has no core"); - return; - } - const root = gameState.core.root; - if (!root) { - logger.warn("Root is not initialized"); - return; - } - - logger.log("Sending event", category, value); - - this.sendToApi("/v1/game-event", { - playerKey: this.syncKey, - gameKey: savegameId, - ingameTime: root.time.now(), - environment: this.environment, - category, - value, - version: G_BUILD_VERSION, - level: root.hubGoals.level, - gameDump: this.generateGameDump(root), - }); - } - - sendTimePoints() { - const gameState = this.app.stateMgr.currentState; - if (gameState instanceof InGameState) { - logger.log("Syncing analytics"); - this.sendGameEvent("sync", ""); - } - } - - /** - * Returns true if the shape is interesting - * @param {string} key - */ - isInterestingShape(key) { - if (key === blueprintShape) { - return true; - } - - // Check if its a story goal - for (let i = 0; i < tutorialGoals.length; ++i) { - if (key === tutorialGoals[i].shape) { - return true; - } - } - - // Check if its required to unlock an upgrade - for (const upgradeKey in UPGRADES) { - const handle = UPGRADES[upgradeKey]; - const tiers = handle.tiers; - for (let i = 0; i < tiers.length; ++i) { - const tier = tiers[i]; - const required = tier.required; - for (let k = 0; k < required.length; ++k) { - if (required[k].shape === key) { - return true; - } - } - } - } - - return false; - } - - /** - * Generates a game dump - * @param {GameRoot} root - */ - generateGameDump(root) { - const shapeIds = Object.keys(root.hubGoals.storedShapes).filter(this.isInterestingShape.bind(this)); - let shapes = {}; - for (let i = 0; i < shapeIds.length; ++i) { - shapes[shapeIds[i]] = root.hubGoals.storedShapes[shapeIds[i]]; - } - return { - shapes, - upgrades: root.hubGoals.upgradeLevels, - belts: root.entityMgr.getAllWithComponent(BeltComponent).length, - buildings: - root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length - - root.entityMgr.getAllWithComponent(BeltComponent).length, - }; - } - - /** - */ - handleGameStarted() { - this.sendGameEvent("game_start", ""); - } - - /** - */ - handleGameResumed() { - this.sendTimePoints(); - } - - /** - * Handles the given level completed - * @param {number} level - */ - handleLevelCompleted(level) { - logger.log("Complete level", level); - this.sendGameEvent("level_complete", "" + level); - } - - /** - * Handles the given upgrade completed - * @param {string} id - * @param {number} level - */ - handleUpgradeUnlocked(id, level) { - logger.log("Unlock upgrade", id, level); - this.sendGameEvent("upgrade_unlock", id + "@" + level); - } -} +import { globalConfig } from "../../core/config"; +import { createLogger } from "../../core/logging"; +import { GameRoot } from "../../game/root"; +import { InGameState } from "../../states/ingame"; +import { GameAnalyticsInterface } from "../game_analytics"; +import { FILE_NOT_FOUND } from "../storage"; +import { blueprintShape, UPGRADES } from "../../game/upgrades"; +import { tutorialGoals } from "../../game/tutorial_goals"; +import { BeltComponent } from "../../game/components/belt"; +import { StaticMapEntityComponent } from "../../game/components/static_map_entity"; + +const logger = createLogger("game_analytics"); + +const analyticsUrl = G_IS_DEV ? "http://localhost:8001" : "https://analytics.shapez.io"; + +// Be sure to increment the ID whenever it changes to make sure all +// users are tracked +const analyticsLocalFile = "shapez_token_123.bin"; + +export class ShapezGameAnalytics extends GameAnalyticsInterface { + get environment() { + if (G_IS_DEV) { + return "dev"; + } + + if (G_IS_STANDALONE) { + return "steam"; + } + + if (G_IS_RELEASE) { + return "prod"; + } + + return "beta"; + } + + /** + * @returns {Promise} + */ + initialize() { + this.syncKey = null; + + setInterval(() => this.sendTimePoints(), 60 * 1000); + + // Retrieve sync key from player + return this.app.storage.readFileAsync(analyticsLocalFile).then( + syncKey => { + this.syncKey = syncKey; + logger.log("Player sync key read:", this.syncKey); + }, + error => { + // File was not found, retrieve new key + if (error === FILE_NOT_FOUND) { + logger.log("Retrieving new player key"); + + // Perform call to get a new key from the API + this.sendToApi("/v1/register", { + environment: this.environment, + }) + .then(res => { + // Try to read and parse the key from the api + if (res.key && typeof res.key === "string" && res.key.length === 40) { + this.syncKey = res.key; + logger.log("Key retrieved:", this.syncKey); + this.app.storage.writeFileAsync(analyticsLocalFile, res.key); + } else { + throw new Error("Bad response from analytics server: " + res); + } + }) + .catch(err => { + logger.error("Failed to register on analytics api:", err); + }); + } else { + logger.error("Failed to read ga key:", error); + } + return; + } + ); + } + + /** + * Sends a request to the api + * @param {string} endpoint Endpoint without base url + * @param {object} data payload + * @returns {Promise} + */ + sendToApi(endpoint, data) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject("Request to " + endpoint + " timed out"), 20000); + + fetch(analyticsUrl + endpoint, { + method: "POST", + mode: "cors", + cache: "no-cache", + referrer: "no-referrer", + credentials: "omit", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-key": globalConfig.info.analyticsApiKey, + }, + body: JSON.stringify(data), + }) + .then(res => { + clearTimeout(timeout); + if (!res.ok || res.status !== 200) { + reject("Fetch error: Bad status " + res.status); + } else { + return res.json(); + } + }) + .then(resolve) + .catch(reason => { + clearTimeout(timeout); + reject(reason); + }); + }); + } + + /** + * Sends a game event to the analytics + * @param {string} category + * @param {string} value + */ + sendGameEvent(category, value) { + if (!this.syncKey) { + logger.warn("Can not send event due to missing sync key"); + return; + } + + const gameState = this.app.stateMgr.currentState; + if (!(gameState instanceof InGameState)) { + logger.warn("Trying to send analytics event outside of ingame state"); + return; + } + + const savegame = gameState.savegame; + if (!savegame) { + logger.warn("Ingame state has empty savegame"); + return; + } + + const savegameId = savegame.internalId; + if (!gameState.core) { + logger.warn("Game state has no core"); + return; + } + const root = gameState.core.root; + if (!root) { + logger.warn("Root is not initialized"); + return; + } + + logger.log("Sending event", category, value); + + this.sendToApi("/v1/game-event", { + playerKey: this.syncKey, + gameKey: savegameId, + ingameTime: root.time.now(), + environment: this.environment, + category, + value, + version: G_BUILD_VERSION, + level: root.hubGoals.level, + gameDump: this.generateGameDump(root), + }); + } + + sendTimePoints() { + const gameState = this.app.stateMgr.currentState; + if (gameState instanceof InGameState) { + logger.log("Syncing analytics"); + this.sendGameEvent("sync", ""); + } + } + + /** + * Returns true if the shape is interesting + * @param {string} key + */ + isInterestingShape(key) { + if (key === blueprintShape) { + return true; + } + + // Check if its a story goal + for (let i = 0; i < tutorialGoals.length; ++i) { + if (key === tutorialGoals[i].shape) { + return true; + } + } + + // Check if its required to unlock an upgrade + for (const upgradeKey in UPGRADES) { + const handle = UPGRADES[upgradeKey]; + const tiers = handle.tiers; + for (let i = 0; i < tiers.length; ++i) { + const tier = tiers[i]; + const required = tier.required; + for (let k = 0; k < required.length; ++k) { + if (required[k].shape === key) { + return true; + } + } + } + } + + return false; + } + + /** + * Generates a game dump + * @param {GameRoot} root + */ + generateGameDump(root) { + const shapeIds = Object.keys(root.hubGoals.storedShapes).filter(this.isInterestingShape.bind(this)); + let shapes = {}; + for (let i = 0; i < shapeIds.length; ++i) { + shapes[shapeIds[i]] = root.hubGoals.storedShapes[shapeIds[i]]; + } + return { + shapes, + upgrades: root.hubGoals.upgradeLevels, + belts: root.entityMgr.getAllWithComponent(BeltComponent).length, + buildings: + root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length - + root.entityMgr.getAllWithComponent(BeltComponent).length, + }; + } + + /** + */ + handleGameStarted() { + this.sendGameEvent("game_start", ""); + } + + /** + */ + handleGameResumed() { + this.sendTimePoints(); + } + + /** + * Handles the given level completed + * @param {number} level + */ + handleLevelCompleted(level) { + logger.log("Complete level", level); + this.sendGameEvent("level_complete", "" + level); + } + + /** + * Handles the given upgrade completed + * @param {string} id + * @param {number} level + */ + handleUpgradeUnlocked(id, level) { + logger.log("Unlock upgrade", id, level); + this.sendGameEvent("upgrade_unlock", id + "@" + level); + } +}