From 9898916807278af988eb019b25ab5b805392209b Mon Sep 17 00:00:00 2001 From: tobspr Date: Wed, 13 May 2020 18:04:51 +0200 Subject: [PATCH] Statistics tab --- res/ui/icons/close.png | Bin 3885 -> 2463 bytes res/ui/icons/display_icons.png | Bin 0 -> 1732 bytes res/ui/icons/display_list.png | Bin 0 -> 838 bytes src/css/ingame_hud/statistics.scss | 163 ++++++++++ src/css/main.scss | 5 +- src/js/core/config.js | 5 + src/js/core/polyfills.js | 17 + src/js/core/utils.js | 17 + src/js/game/core.js | 10 +- src/js/game/hub_goals.js | 12 +- src/js/game/hud/hud.js | 2 + src/js/game/hud/parts/game_menu.js | 2 +- src/js/game/hud/parts/shop.js | 4 +- src/js/game/hud/parts/statistics.js | 397 ++++++++++++++++++++++++ src/js/game/production_analytics.js | 116 +++++++ src/js/game/root.js | 7 + src/js/game/shape_definition.js | 2 +- src/js/game/shape_definition_manager.js | 13 + src/js/game/systems/item_processor.js | 5 + src/js/game/systems/miner.js | 6 + src/js/globals.d.ts | 4 + 21 files changed, 770 insertions(+), 17 deletions(-) create mode 100644 res/ui/icons/display_icons.png create mode 100644 res/ui/icons/display_list.png create mode 100644 src/css/ingame_hud/statistics.scss create mode 100644 src/js/game/hud/parts/statistics.js create mode 100644 src/js/game/production_analytics.js diff --git a/res/ui/icons/close.png b/res/ui/icons/close.png index a03a297680f891a33b1dfb5577b414f429cafac9..e4108d12fb9169bb129ba3fae00bc9e2361f25c4 100644 GIT binary patch literal 2463 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%0AG} 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 .noEntries { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + @include PlainText; + color: #aaa; + } + + > div { + background: #f4f4f4; + @include S(border-radius, 2px); + @include S(margin-bottom, 4px); + display: grid; + + grid-template-columns: 1fr auto; + @include S(padding, 5px); + &:last-child { + margin-bottom: 0; + } + + canvas.icon { + grid-column: 1 / 2; + grid-row: 1 / 2; + @include S(width, 40px); + @include S(height, 40px); + } + + .counter { + @include SuperSmallText; + + @include S(border-radius, 2px); + @include S(padding, 0, 3px); + } + } + } + + .dialogInner { + &[data-displaymode="detailed"] .displayDetailed, + &[data-displaymode="icons"] .displayIcons, + &[data-datasource="produced"] .modeProduced, + &[data-datasource="delivered"] .modeDelivered, + &[data-datasource="stored"] .modeStored { + opacity: 1; + } + + &[data-displaymode="icons"] .content.hasEntries { + display: grid; + grid-template-columns: repeat(6, 1fr); + grid-auto-rows: #{D(73px)}; + align-items: flex-start; + @include S(grid-column-gap, 3px); + > div { + @include S(grid-row-gap, 5px); + @include S(height, 60px); + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + justify-items: center; + align-items: center; + + .counter { + grid-column: 1 / 2; + grid-row: 2 / 3; + background: rgba(0, 10, 20, 0.05); + justify-self: end; + } + } + } + &[data-displaymode="detailed"] .content.hasEntries { + > div { + @include S(padding, 10px); + @include S(height, 40px); + grid-template-columns: auto 1fr auto; + @include S(grid-column-gap, 15px); + + .counter { + grid-column: 3 / 4; + grid-row: 1 / 2; + @include Heading; + color: #55595a; + } + + canvas.graph { + @include S(width, 300px); + @include S(height, 40px); + @include S(border-radius, 0, 0, 2px, 2px); + $color: rgba(0, 10, 20, 0.04); + // background: $color; + border: #{D(4px)} solid transparent; + // @include S(border-width, 1px, 0, 1px, 0); + @include S(margin-top, -3px); + } + } + } + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 4aa0aa31..46107002 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -35,11 +35,12 @@ @import "ingame_hud/dialogs"; @import "ingame_hud/mass_selector"; @import "ingame_hud/vignette_overlay"; +@import "ingame_hud/statistics"; // Z-Index $elements: ingame_Canvas, ingame_VignetteOverlay, ingame_HUD_building_placer, ingame_HUD_buildings_toolbar, - ingame_HUD_GameMenu, ingame_HUD_KeybindingOverlay, ingame_HUD_Shop, ingame_HUD_BetaOverlay, - ingame_HUD_MassSelector, ingame_HUD_UnlockNotification; + ingame_HUD_GameMenu, ingame_HUD_KeybindingOverlay, ingame_HUD_Shop, ingame_HUD_Statistics, + ingame_HUD_BetaOverlay, ingame_HUD_MassSelector, ingame_HUD_UnlockNotification; $zindex: 100; diff --git a/src/js/core/config.js b/src/js/core/config.js index 5fe775b4..fe18234f 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -18,6 +18,9 @@ export const globalConfig = { assetsSharpness: 1.2, shapesSharpness: 1.4, + statisticsGraphDpi: 2.5, + statisticsGraphSlices: 100, + // [Calculated] physics step size physicsDeltaMs: 0, physicsDeltaSeconds: 0, @@ -38,6 +41,8 @@ export const globalConfig = { undergroundBeltMaxTiles: 5, + analyticsSliceDurationSeconds: 10, + buildingSpeeds: { cutter: 1 / 4, rotater: 1 / 1, diff --git a/src/js/core/polyfills.js b/src/js/core/polyfills.js index 22688836..64e6c6b9 100644 --- a/src/js/core/polyfills.js +++ b/src/js/core/polyfills.js @@ -48,9 +48,26 @@ function stringPolyfills() { } } +function objectPolyfills() { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries + // @ts-ignore + if (!Object.entries) { + // @ts-ignore + Object.entries = function (obj) { + var ownProps = Object.keys(obj), + i = ownProps.length, + resArray = new Array(i); // preallocate the Array + while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; + + return resArray; + }; + } +} + function initPolyfills() { mathPolyfills(); stringPolyfills(); + objectPolyfills(); } function initExtensions() { diff --git a/src/js/core/utils.js b/src/js/core/utils.js index 2b7eca48..541662a2 100644 --- a/src/js/core/utils.js +++ b/src/js/core/utils.js @@ -725,6 +725,23 @@ export function makeDiv(parent, id = null, classes = [], innerHTML = "") { return div; } +/** + * Helper method to create a new button + * @param {Element} parent + * @param {Array=} classes + * @param {string=} innerHTML + */ +export function makeButton(parent, classes = [], innerHTML = "") { + const element = document.createElement("button"); + for (let i = 0; i < classes.length; ++i) { + element.classList.add(classes[i]); + } + element.classList.add("styledButton"); + element.innerHTML = innerHTML; + parent.appendChild(element); + return element; +} + /** * Removes all children of the given element * @param {Element} elem diff --git a/src/js/game/core.js b/src/js/game/core.js index 02f08678..b581b089 100644 --- a/src/js/game/core.js +++ b/src/js/game/core.js @@ -30,6 +30,7 @@ import { GameRoot } from "./root"; import { ShapeDefinitionManager } from "./shape_definition_manager"; import { SoundProxy } from "./sound_proxy"; import { GameTime } from "./time/game_time"; +import { ProductionAnalytics } from "./production_analytics"; const logger = createLogger("ingame/core"); @@ -109,13 +110,11 @@ export class GameCore { root.entityMgr = new EntityManager(root); root.systemMgr = new GameSystemManager(root); root.shapeDefinitionMgr = new ShapeDefinitionManager(root); - root.mapNoiseGenerator = new PerlinNoise(Math.random()); // TODO: Save seed root.hubGoals = new HubGoals(root); + root.productionAnalytics = new ProductionAnalytics(root); + root.mapNoiseGenerator = new PerlinNoise(Math.random()); // TODO: Save seed root.buffers = new BufferMaintainer(root); - // root.particleMgr = new ParticleManager(root); - // root.uiParticleMgr = new ParticleManager(root); - // Initialize the hud once everything is loaded this.root.hud.initialize(); @@ -260,6 +259,9 @@ export class GameCore { // root.uiParticleMgr.update(); } + // Update analytics + root.productionAnalytics.update(); + // Update automatic save after everything finished root.automaticSave.update(); diff --git a/src/js/game/hub_goals.js b/src/js/game/hub_goals.js index 510cef5b..537a618a 100644 --- a/src/js/game/hub_goals.js +++ b/src/js/game/hub_goals.js @@ -1,7 +1,7 @@ import { BasicSerializableObject } from "../savegame/serialization"; import { GameRoot } from "./root"; import { ShapeDefinition, enumSubShape } from "./shape_definition"; -import { enumColors } from "./colors"; +import { enumColors, enumShortcodeToColor, enumColorToShortcode } from "./colors"; import { randomChoice, clamp, randomInt, findNiceIntegerValue } from "../core/utils"; import { tutorialGoals, enumHubGoalRewards } from "./tutorial_goals"; import { createLogger } from "../core/logging"; @@ -114,6 +114,8 @@ export class HubGoals extends BasicSerializableObject { const hash = definition.getHash(); this.storedShapes[hash] = (this.storedShapes[hash] || 0) + 1; + this.root.signals.shapeDelivered.dispatch(definition); + // Check if we have enough for the next level const targetHash = this.currentGoal.definition.getHash(); if ( @@ -133,9 +135,7 @@ export class HubGoals extends BasicSerializableObject { const { shape, required, reward } = tutorialGoals[storyIndex]; this.currentGoal = { /** @type {ShapeDefinition} */ - definition: this.root.shapeDefinitionMgr.registerOrReturnHandle( - ShapeDefinition.fromShortKey(shape) - ), + definition: this.root.shapeDefinitionMgr.getShapeFromShortKey(shape), required, reward, }; @@ -320,14 +320,14 @@ export class HubGoals extends BasicSerializableObject { case enumItemProcessorTypes.hub: return 1e30; case enumItemProcessorTypes.splitter: - return (2 / globalConfig.beltSpeedItemsPerSecond) * this.upgradeImprovements.belt; + return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt * 2; case enumItemProcessorTypes.cutter: case enumItemProcessorTypes.rotater: case enumItemProcessorTypes.stacker: case enumItemProcessorTypes.mixer: case enumItemProcessorTypes.painter: return ( - (1 / globalConfig.beltSpeedItemsPerSecond) * + globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.processors * globalConfig.buildingSpeeds[processorType] ); diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index 7533585a..7cd3d461 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -15,6 +15,7 @@ import { HUDShop } from "./parts/shop"; import { IS_MOBILE } from "../../core/config"; import { HUDMassSelector } from "./parts/mass_selector"; import { HUDVignetteOverlay } from "./parts/vignette_overlay"; +import { HUDStatistics } from "./parts/statistics"; export class GameHUD { /** @@ -45,6 +46,7 @@ export class GameHUD { massSelector: new HUDMassSelector(this.root), shop: new HUDShop(this.root), + statistics: new HUDStatistics(this.root), vignetteOverlay: new HUDVignetteOverlay(this.root), diff --git a/src/js/game/hud/parts/game_menu.js b/src/js/game/hud/parts/game_menu.js index 01b91bbb..3a49d598 100644 --- a/src/js/game/hud/parts/game_menu.js +++ b/src/js/game/hud/parts/game_menu.js @@ -16,7 +16,7 @@ export class HUDGameMenu extends BaseHUDPart { { id: "stats", label: "Stats", - handler: () => null, + handler: () => this.root.hud.parts.statistics.show(), keybinding: "menu_open_stats", }, ]; diff --git a/src/js/game/hud/parts/shop.js b/src/js/game/hud/parts/shop.js index db92ded3..d1c30f17 100644 --- a/src/js/game/hud/parts/shop.js +++ b/src/js/game/hud/parts/shop.js @@ -88,9 +88,7 @@ export class HUDShop extends BaseHUDPart { tierHandle.required.forEach(({ shape, amount }) => { const requireDiv = makeDiv(handle.elemRequirements, null, ["requirement"]); - const shapeDef = this.root.shapeDefinitionMgr.registerOrReturnHandle( - ShapeDefinition.fromShortKey(shape) - ); + const shapeDef = this.root.shapeDefinitionMgr.getShapeFromShortKey(shape); const shapeCanvas = shapeDef.generateAsCanvas(120); shapeCanvas.classList.add(); requireDiv.appendChild(shapeCanvas); diff --git a/src/js/game/hud/parts/statistics.js b/src/js/game/hud/parts/statistics.js new file mode 100644 index 00000000..2e0e3dfb --- /dev/null +++ b/src/js/game/hud/parts/statistics.js @@ -0,0 +1,397 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { + makeDiv, + makeButton, + formatBigNumber, + clamp, + removeAllChildren, + waitNextFrame, +} from "../../../core/utils"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { InputReceiver } from "../../../core/input_receiver"; +import { KeyActionMapper } from "../../key_action_mapper"; +import { ShapeDefinition } from "../../shape_definition"; +import { GameRoot } from "../../root"; +import { freeCanvas, makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { enumAnalyticsDataSource } from "../../production_analytics"; +import { globalConfig } from "../../../core/config"; +import { Math_floor, Math_min } from "../../../core/builtins"; + +/** @enum {string} */ +const enumDisplayMode = { + icons: "icons", + detailed: "detailed", +}; + +/** + * Simple wrapper for a shape definition + */ +class ShapeStatisticsHandle { + /** + * @param {GameRoot} root + * @param {ShapeDefinition} definition + * @param {IntersectionObserver} intersectionObserver + */ + constructor(root, definition, intersectionObserver) { + this.definition = definition; + this.root = root; + this.intersectionObserver = intersectionObserver; + + this.visible = false; + } + + initElement() { + this.element = document.createElement("div"); + this.element.setAttribute("data-shape-key", this.definition.getHash()); + + this.counter = document.createElement("span"); + this.counter.classList.add("counter"); + this.element.appendChild(this.counter); + } + + /** + * Sets whether the shape handle is visible currently + * @param {boolean} visibility + */ + setVisible(visibility) { + if (visibility === this.visible) { + return; + } + this.visible = visibility; + if (visibility) { + if (!this.shapeCanvas) { + // Create elements + this.shapeCanvas = this.definition.generateAsCanvas(100); + this.shapeCanvas.classList.add("icon"); + this.element.appendChild(this.shapeCanvas); + } + } else { + // Drop elements + if (this.shapeCanvas) { + this.shapeCanvas.remove(); + delete this.shapeCanvas; + } + if (this.graphCanvas) { + this.graphCanvas.remove(); + delete this.graphCanvas; + delete this.graphContext; + } + } + } + + /** + * + * @param {enumDisplayMode} displayMode + * @param {enumAnalyticsDataSource} dataSource + * @param {boolean=} forced + */ + update(displayMode, dataSource, forced = false) { + if (!this.element) { + return; + } + if (!this.visible && !forced) { + return; + } + + switch (dataSource) { + case enumAnalyticsDataSource.stored: { + this.counter.innerText = formatBigNumber( + this.root.hubGoals.storedShapes[this.definition.getHash()] || 0 + ); + break; + } + case enumAnalyticsDataSource.delivered: + case enumAnalyticsDataSource.produced: { + let rate = + (this.root.productionAnalytics.getCurrentShapeRate(dataSource, this.definition) / + globalConfig.analyticsSliceDurationSeconds) * + 60; + this.counter.innerText = formatBigNumber(rate) + " / m"; + break; + } + } + + if (displayMode === enumDisplayMode.detailed) { + const graphDpi = globalConfig.statisticsGraphDpi; + + const w = 300; + const h = 40; + + if (!this.graphCanvas) { + const [canvas, context] = makeOffscreenBuffer(w * graphDpi, h * graphDpi, { + smooth: true, + reusable: false, + label: "statgraph-" + this.definition.getHash(), + }); + context.scale(graphDpi, graphDpi); + canvas.classList.add("graph"); + this.graphCanvas = canvas; + this.graphContext = context; + this.element.appendChild(this.graphCanvas); + } + + this.graphContext.clearRect(0, 0, w, h); + + this.graphContext.fillStyle = "#bee0db"; + this.graphContext.strokeStyle = "#66ccbc"; + this.graphContext.lineWidth = 1.5; + + const sliceWidth = w / globalConfig.statisticsGraphSlices; + + let values = []; + let maxValue = 1; + + for (let i = 0; i < globalConfig.statisticsGraphSlices - 1; ++i) { + const value = this.root.productionAnalytics.getPastShapeRate( + dataSource, + this.definition, + globalConfig.statisticsGraphSlices - i - 1 + ); + if (value > maxValue) { + maxValue = value; + } + values.push(value); + } + + this.graphContext.beginPath(); + this.graphContext.moveTo(0.75, h + 5); + for (let i = 0; i < values.length; ++i) { + const yValue = clamp((1 - values[i] / maxValue) * h, 0.75, h - 0.75); + const x = i * sliceWidth; + if (i === 0) { + this.graphContext.lineTo(0.75, yValue); + } + this.graphContext.lineTo(x, yValue); + if (i === values.length - 1) { + this.graphContext.lineTo(w + 100, yValue); + this.graphContext.lineTo(w + 100, h + 5); + } + } + + this.graphContext.closePath(); + this.graphContext.stroke(); + this.graphContext.fill(); + } else { + if (this.graphCanvas) { + this.graphCanvas.remove(); + delete this.graphCanvas; + delete this.graphContext; + } + } + } + + /** + * Attaches the handle + * @param {HTMLElement} parent + */ + attach(parent) { + if (!this.element) { + this.initElement(); + } + if (this.element.parentElement !== parent) { + parent.appendChild(this.element); + this.intersectionObserver.observe(this.element); + } + } + + /** + * Detaches the handle + */ + detach() { + if (this.element && this.element.parentElement) { + this.element.parentElement.removeChild(this.element); + this.intersectionObserver.unobserve(this.element); + } + } + + /** + * Destroys the handle + */ + destroy() { + if (this.element) { + this.intersectionObserver.unobserve(this.element); + this.shapeCanvas.remove(); + + this.element.remove(); + delete this.element; + delete this.counter; + delete this.shapeCanvas; + } + } +} + +export class HUDStatistics extends BaseHUDPart { + createElements(parent) { + this.background = makeDiv(parent, "ingame_HUD_Statistics", ["ingameDialog"]); + + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], `statistics`); + this.closeButton = makeDiv(this.title, null, ["closeButton"]); + this.trackClicks(this.closeButton, this.close); + + this.filterHeader = makeDiv(this.dialogInner, null, ["filterHeader"]); + + this.filtersDataSource = makeDiv(this.filterHeader, null, ["filtersDataSource"]); + this.filtersDisplayMode = makeDiv(this.filterHeader, null, ["filtersDisplayMode"]); + + const buttonModeProduced = makeButton(this.filtersDataSource, ["modeProduced"], "Produced"); + const buttonModeDelivered = makeButton(this.filtersDataSource, ["modeDelivered"], "Delivered"); + const buttonModeStored = makeButton(this.filtersDataSource, ["modeStored"], "Stored"); + + this.trackClicks(buttonModeProduced, () => this.setDataSource(enumAnalyticsDataSource.produced)); + this.trackClicks(buttonModeStored, () => this.setDataSource(enumAnalyticsDataSource.stored)); + this.trackClicks(buttonModeDelivered, () => this.setDataSource(enumAnalyticsDataSource.delivered)); + + const buttonDisplayDetailed = makeButton(this.filtersDisplayMode, ["displayDetailed"]); + const buttonDisplayIcons = makeButton(this.filtersDisplayMode, ["displayIcons"]); + + this.trackClicks(buttonDisplayIcons, () => this.setDisplayMode(enumDisplayMode.icons)); + this.trackClicks(buttonDisplayDetailed, () => this.setDisplayMode(enumDisplayMode.detailed)); + + this.contentDiv = makeDiv(this.dialogInner, null, ["content"]); + } + + /** + * @param {enumAnalyticsDataSource} source + */ + setDataSource(source) { + this.dataSource = source; + this.dialogInner.setAttribute("data-datasource", source); + if (this.visible) { + this.rerenderFull(); + } + } + + /** + * @param {enumDisplayMode} mode + */ + setDisplayMode(mode) { + this.displayMode = mode; + this.dialogInner.setAttribute("data-displaymode", mode); + if (this.visible) { + this.rerenderFull(); + } + } + + initialize() { + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + + this.inputReciever = new InputReceiver("statistics"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + + this.keyActionMapper.getBinding("back").add(this.close, this); + this.keyActionMapper.getBinding("menu_open_stats").add(this.close, this); + + /** @type {Object.} */ + this.activeHandles = {}; + + this.setDataSource(enumAnalyticsDataSource.produced); + this.setDisplayMode(enumDisplayMode.detailed); + + this.intersectionObserver = new IntersectionObserver(this.intersectionCallback.bind(this), { + root: this.contentDiv, + }); + + this.lastFullRerender = 0; + + this.close(); + this.rerenderFull(); + } + + intersectionCallback(entries) { + for (let i = 0; i < entries.length; ++i) { + const entry = entries[i]; + const handle = this.activeHandles[entry.target.getAttribute("data-shape-key")]; + if (handle) { + handle.setVisible(entry.intersectionRatio > 0); + } + } + } + + cleanup() { + document.body.classList.remove("ingameDialogOpen"); + } + + show() { + this.visible = true; + document.body.classList.add("ingameDialogOpen"); + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.rerenderFull(); + this.update(); + } + + close() { + this.visible = false; + document.body.classList.remove("ingameDialogOpen"); + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + + update() { + this.domAttach.update(this.visible); + if (this.visible) { + if (this.root.time.now() - this.lastFullRerender > 1) { + this.lastFullRerender = this.root.time.now(); + this.lastPartialRerender = this.root.time.now(); + this.rerenderFull(); + } + this.rerenderPartial(); + } + } + + rerenderPartial() { + for (const key in this.activeHandles) { + const handle = this.activeHandles[key]; + handle.update(this.displayMode, this.dataSource); + } + } + + rerenderFull() { + removeAllChildren(this.contentDiv); + + // Now, attach new ones + const entries = Object.entries(this.root.hubGoals.storedShapes); + entries.sort((a, b) => b[1] - a[1]); + + let rendered = new Set(); + + for (let i = 0; i < Math_min(entries.length, 200); ++i) { + const entry = entries[i]; + const shapeKey = entry[0]; + const amount = entry[1]; + if (amount < 1) { + continue; + } + + let handle = this.activeHandles[shapeKey]; + if (!handle) { + const definition = this.root.shapeDefinitionMgr.getShapeFromShortKey(shapeKey); + handle = this.activeHandles[shapeKey] = new ShapeStatisticsHandle( + this.root, + definition, + this.intersectionObserver + ); + } + + rendered.add(shapeKey); + handle.attach(this.contentDiv); + } + + for (const key in this.activeHandles) { + if (!rendered.has(key)) { + this.activeHandles[key].destroy(); + delete this.activeHandles[key]; + } + } + + if (entries.length === 0) { + this.contentDiv.innerHTML = ` + No shapes have been produced so far.`; + } + + this.contentDiv.classList.toggle("hasEntries", entries.length > 0); + } +} diff --git a/src/js/game/production_analytics.js b/src/js/game/production_analytics.js new file mode 100644 index 00000000..63c5083e --- /dev/null +++ b/src/js/game/production_analytics.js @@ -0,0 +1,116 @@ +import { GameRoot } from "./root"; +import { ShapeDefinition } from "./shape_definition"; +import { globalConfig } from "../core/config"; + +/** @enum {string} */ +export const enumAnalyticsDataSource = { + produced: "produced", + stored: "stored", + delivered: "delivered", +}; + +export class ProductionAnalytics { + /** + * @param {GameRoot} root + */ + constructor(root) { + this.root = root; + + this.history = { + [enumAnalyticsDataSource.produced]: [], + [enumAnalyticsDataSource.stored]: [], + [enumAnalyticsDataSource.delivered]: [], + }; + + for (let i = 0; i < globalConfig.statisticsGraphSlices; ++i) { + this.startNewSlice(); + } + + this.root.signals.shapeDelivered.add(this.onShapeDelivered, this); + this.root.signals.shapeProduced.add(this.onShapeProduced, this); + + this.lastAnalyticsSlice = 0; + } + + /** + * @param {ShapeDefinition} definition + */ + onShapeDelivered(definition) { + const key = definition.getHash(); + const entry = this.history[enumAnalyticsDataSource.delivered]; + entry[entry.length - 1][key] = (entry[entry.length - 1][key] || 0) + 1; + } + + /** + * @param {ShapeDefinition} definition + */ + onShapeProduced(definition) { + const key = definition.getHash(); + const entry = this.history[enumAnalyticsDataSource.produced]; + entry[entry.length - 1][key] = (entry[entry.length - 1][key] || 0) + 1; + } + + /** + * Starts a new time slice + */ + startNewSlice() { + for (const key in this.history) { + if (key === enumAnalyticsDataSource.stored) { + // Copy stored data + this.history[key].push(Object.assign({}, this.root.hubGoals.storedShapes)); + } else { + this.history[key].push({}); + } + while (this.history[key].length > globalConfig.statisticsGraphSlices) { + this.history[key].shift(); + } + } + } + + /** + * @param {ShapeDefinition} definition + */ + getCurrentShapeProductionRate(definition) { + const slices = this.history[enumAnalyticsDataSource.produced]; + return slices[slices.length - 2][definition.getHash()] || 0; + } + + /** + * @param {ShapeDefinition} definition + */ + getCurrentShapeDeliverRate(definition) { + const slices = this.history[enumAnalyticsDataSource.delivered]; + return slices[slices.length - 2][definition.getHash()] || 0; + } + /** + * @param {enumAnalyticsDataSource} dataSource + * @param {ShapeDefinition} definition + */ + getCurrentShapeRate(dataSource, definition) { + const slices = this.history[dataSource]; + return slices[slices.length - 2][definition.getHash()] || 0; + } + + /** + * + * @param {enumAnalyticsDataSource} dataSource + * @param {ShapeDefinition} definition + * @param {number} historyOffset + */ + getPastShapeRate(dataSource, definition, historyOffset) { + assertAlways( + historyOffset >= 0 && historyOffset < globalConfig.statisticsGraphSlices, + "Invalid slice offset: " + historyOffset + ); + + const slices = this.history[dataSource]; + return slices[slices.length - 1 - historyOffset][definition.getHash()] || 0; + } + + update() { + if (this.root.time.now() - this.lastAnalyticsSlice > globalConfig.analyticsSliceDurationSeconds) { + this.lastAnalyticsSlice = this.root.time.now(); + this.startNewSlice(); + } + } +} diff --git a/src/js/game/root.js b/src/js/game/root.js index 7a6910f3..8bf5066c 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -27,6 +27,7 @@ import { CanvasClickInterceptor } from "./canvas_click_interceptor"; import { PerlinNoise } from "../core/perlin_noise"; import { HubGoals } from "./hub_goals"; import { BufferMaintainer } from "../core/buffer_maintainer"; +import { ProductionAnalytics } from "./production_analytics"; /* typehints:end */ const logger = createLogger("game/root"); @@ -125,6 +126,9 @@ export class GameRoot { /** @type {ShapeDefinitionManager} */ this.shapeDefinitionMgr = null; + /** @type {ProductionAnalytics} */ + this.productionAnalytics = null; + this.signals = { // Entities entityAdded: new Signal(/* entity */), @@ -150,6 +154,9 @@ export class GameRoot { // Can be used to trigger an async task performAsync: new Signal(), + + shapeDelivered: new Signal(/* definition */), + shapeProduced: new Signal(/* definition */), }; // RNG's diff --git a/src/js/game/shape_definition.js b/src/js/game/shape_definition.js index a3f91224..166b329d 100644 --- a/src/js/game/shape_definition.js +++ b/src/js/game/shape_definition.js @@ -197,7 +197,7 @@ export class ShapeDefinition extends BasicSerializableObject { * Generates this shape as a canvas * @param {number} size */ - generateAsCanvas(size = 20) { + generateAsCanvas(size = 120) { const [canvas, context] = makeOffscreenBuffer(size, size, { smooth: true, label: "definition-canvas-cache-" + this.getHash(), diff --git a/src/js/game/shape_definition_manager.js b/src/js/game/shape_definition_manager.js index 25f456cd..e3ef6e6e 100644 --- a/src/js/game/shape_definition_manager.js +++ b/src/js/game/shape_definition_manager.js @@ -26,6 +26,19 @@ export class ShapeDefinitionManager extends BasicSerializableObject { this.operationCache = {}; } + /** + * + * @param {string} hash + * @returns {ShapeDefinition} + */ + getShapeFromShortKey(hash) { + const cached = this.shapeKeyToDefinition[hash]; + if (cached) { + return cached; + } + return (this.shapeKeyToDefinition[hash] = ShapeDefinition.fromShortKey(hash)); + } + /** * Registers a new shape definition * @param {ShapeDefinition} definition diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js index 63e921a8..7937f642 100644 --- a/src/js/game/systems/item_processor.js +++ b/src/js/game/systems/item_processor.js @@ -157,9 +157,11 @@ export class ItemProcessorSystem extends GameSystemWithFilter { item: new ShapeItem(cutDefinition1), requiredSlot: 0, }); + this.root.signals.shapeProduced.dispatch(cutDefinition1); } if (!cutDefinition2.isEntirelyEmpty()) { + this.root.signals.shapeProduced.dispatch(cutDefinition2); outItems.push({ item: new ShapeItem(cutDefinition2), requiredSlot: 1, @@ -176,6 +178,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter { const inputDefinition = inputItem.definition; const rotatedDefinition = this.root.shapeDefinitionMgr.shapeActionRotateCW(inputDefinition); + this.root.signals.shapeProduced.dispatch(rotatedDefinition); outItems.push({ item: new ShapeItem(rotatedDefinition), }); @@ -197,6 +200,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter { lowerItem.definition, upperItem.definition ); + this.root.signals.shapeProduced.dispatch(stackedDefinition); outItems.push({ item: new ShapeItem(stackedDefinition), }); @@ -249,6 +253,7 @@ export class ItemProcessorSystem extends GameSystemWithFilter { colorItem.color ); + this.root.signals.shapeProduced.dispatch(colorizedDefinition); outItems.push({ item: new ShapeItem(colorizedDefinition), }); diff --git a/src/js/game/systems/miner.js b/src/js/game/systems/miner.js index 4c9d5cca..7d9dc037 100644 --- a/src/js/game/systems/miner.js +++ b/src/js/game/systems/miner.js @@ -3,6 +3,7 @@ import { DrawParameters } from "../../core/draw_parameters"; import { MinerComponent } from "../components/miner"; import { GameSystemWithFilter } from "../game_system_with_filter"; import { MapChunkView } from "../map_chunk_view"; +import { ShapeItem } from "../items/shape_item"; export class MinerSystem extends GameSystemWithFilter { constructor(root) { @@ -36,6 +37,11 @@ export class MinerSystem extends GameSystemWithFilter { continue; } + // Analytics hook + if (lowerLayerItem instanceof ShapeItem) { + this.root.signals.shapeProduced.dispatch(lowerLayerItem.definition); + } + // Try actually ejecting if (!ejectComp.tryEject(0, lowerLayerItem)) { assert(false, "Failed to eject"); diff --git a/src/js/globals.d.ts b/src/js/globals.d.ts index 5a17daa8..e9abe206 100644 --- a/src/js/globals.d.ts +++ b/src/js/globals.d.ts @@ -138,6 +138,10 @@ declare interface Element { innerHTML: string; } +declare interface Object { + entries(obj: object): Array<[string, any]>; +} + declare interface Math { radians(number): number; degrees(number): number;