diff --git a/res/ui/icons/close.png b/res/ui/icons/close.png index a03a2976..e4108d12 100644 Binary files a/res/ui/icons/close.png and b/res/ui/icons/close.png differ diff --git a/res/ui/icons/display_icons.png b/res/ui/icons/display_icons.png new file mode 100644 index 00000000..68fd2838 Binary files /dev/null and b/res/ui/icons/display_icons.png differ diff --git a/res/ui/icons/display_list.png b/res/ui/icons/display_list.png new file mode 100644 index 00000000..665f5b8e Binary files /dev/null and b/res/ui/icons/display_list.png differ diff --git a/src/css/ingame_hud/statistics.scss b/src/css/ingame_hud/statistics.scss new file mode 100644 index 00000000..8188f9ed --- /dev/null +++ b/src/css/ingame_hud/statistics.scss @@ -0,0 +1,163 @@ +#ingame_HUD_Statistics { + .filterHeader { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + justify-items: end; + + .filtersDataSource { + } + + .filtersDisplayMode { + } + + button { + @include S(height, 20px); + @include S(padding, 1px, 10px); + border: 0; + box-shadow: none; + border-radius: 0; + @include IncreasedClickArea(1px); + @include S(min-width, 30px); + color: #fff; + opacity: 0.25; + @include S(margin-left, 1px); + + &.displayIcons, + &.displayDetailed { + background: uiResource("icons/display_list.png") center center / #{D(15px)} no-repeat; + &.displayIcons { + background-image: uiResource("icons/display_icons.png"); + background-size: #{D(11.5px)}; + } + } + + background-color: #44484a !important; + transition: opacity 0.2s ease-in-out; + } + + .filtersDataSource, + .filtersDisplayMode { + display: flex; + padding: 0; + margin: 0; + + :first-child { + border-radius: #{D(2px)} 0 0 #{D(2px)} !important; + margin-left: 0 !important; + } + :last-child { + margin-right: 0 !important; + border-radius: 0 #{D(2px)} #{D(2px)} 0 !important; + } + } + } + + .content { + @include S(margin-top, 10px); + @include S(height, 350px); + overflow-y: scroll; + display: flex; + flex-direction: column; + + justify-content: flex-start; + + @include S(padding-right, 4px); + + > .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;