diff --git a/src/css/ingame_hud/dialogs.scss b/src/css/ingame_hud/dialogs.scss index cc742d42..3aaf4b71 100644 --- a/src/css/ingame_hud/dialogs.scss +++ b/src/css/ingame_hud/dialogs.scss @@ -214,6 +214,35 @@ } } } + + .inline { + display: flex; + flex-direction: row; + align-items: center; + + > * { + @include S(margin-right, 7.5px); + } + } + + .detailsFormElem { + > .object { + pointer-events: all; + + > summary { + transition: opacity 0.1s ease-in-out; + cursor: pointer; + pointer-events: all; + &:hover { + opacity: 0.8; + } + } + > div { + @include S(margin-left, 4px); + cursor: pointer; + } + } + } } > .buttons { diff --git a/src/js/core/modal_dialog_forms.js b/src/js/core/modal_dialog_forms.js index ccf9bfb2..ee2e22f5 100644 --- a/src/js/core/modal_dialog_forms.js +++ b/src/js/core/modal_dialog_forms.js @@ -47,13 +47,67 @@ export class FormElement { } } +export class FormElementDetails extends FormElement { + /** + * + * @param {object} param0 + * @param {string} param0.id + * @param {string} param0.label + * @param {Array} param0.formElements + */ + constructor({ id, label, formElements }) { + super(id, label); + this.formElements = formElements; + + this.element = null; + } + + getHtml() { + return `
+ ${this.label ? `${this.label}` : ""} +
+ ${this.formElements.map(e => e.getHtml()).join("")} +
`; + } + + bindEvents(parent, clickTrackers) { + this.element = this.getFormElement(parent); + + for (let i = 0; i < this.formElements.length; ++i) { + const elem = this.formElements[i]; + elem.bindEvents(parent, clickTrackers); + elem.valueChosen.add(this.valueChosen.dispatch, this.valueChosen); + } + } + + getValue() { + let formElementValues = {}; + for (let i = 0; i < this.formElements.length; ++i) { + const elem = this.formElements[i]; + formElementValues[elem.id] = elem.getValue(); + } + return formElementValues; + } + + focus() {} +} + export class FormElementInput extends FormElement { - constructor({ id, label = null, placeholder, defaultValue = "", inputType = "text", validator = null }) { + constructor({ + id, + label = null, + placeholder, + defaultValue = "", + inputType = "text", + validator = null, + inline = false, + }) { super(id, label); this.placeholder = placeholder; this.defaultValue = defaultValue; this.inputType = inputType; this.validator = validator; + this.inline = inline; this.element = null; } @@ -83,7 +137,7 @@ export class FormElementInput extends FormElement { } return ` -
+
${this.label ? `` : ""} +
${this.label ? `` : ""}
- -
+ +
`; } @@ -181,7 +236,7 @@ export class FormElementCheckbox extends FormElement { this.element.classList.toggle("checked", this.value); } - focus(parent) {} + focus() {} } export class FormElementItemChooser extends FormElement { diff --git a/src/js/game/core.js b/src/js/game/core.js index a0ee3713..2e31ebbf 100644 --- a/src/js/game/core.js +++ b/src/js/game/core.js @@ -1,573 +1,585 @@ -/* typehints:start */ -import { Application } from "../application"; -/* typehints:end */ -import { BufferMaintainer } from "../core/buffer_maintainer"; -import { - disableImageSmoothing, - enableImageSmoothing, - getBufferStats, - registerCanvas, -} from "../core/buffer_utils"; -import { globalConfig } from "../core/config"; -import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager"; -import { DrawParameters } from "../core/draw_parameters"; -import { gMetaBuildingRegistry } from "../core/global_registries"; -import { createLogger } from "../core/logging"; -import { Rectangle } from "../core/rectangle"; -import { ORIGINAL_SPRITE_SCALE } from "../core/sprites"; -import { lerp, randomInt, round2Digits } from "../core/utils"; -import { Vector } from "../core/vector"; -import { Savegame } from "../savegame/savegame"; -import { SavegameSerializer } from "../savegame/savegame_serializer"; -import { AutomaticSave } from "./automatic_save"; -import { MetaHubBuilding } from "./buildings/hub"; -import { Camera } from "./camera"; -import { DynamicTickrate } from "./dynamic_tickrate"; -import { EntityManager } from "./entity_manager"; -import { GameSystemManager } from "./game_system_manager"; -import { HubGoals } from "./hub_goals"; -import { GameHUD } from "./hud/hud"; -import { KeyActionMapper } from "./key_action_mapper"; -import { GameLogic } from "./logic"; -import { MapView } from "./map_view"; -import { defaultBuildingVariant } from "./meta_building"; -import { GameMode } from "./game_mode"; -import { ProductionAnalytics } from "./production_analytics"; -import { GameRoot } from "./root"; -import { ShapeDefinitionManager } from "./shape_definition_manager"; -import { AchievementProxy } from "./achievement_proxy"; -import { SoundProxy } from "./sound_proxy"; -import { GameTime } from "./time/game_time"; - -const logger = createLogger("ingame/core"); - -// Store the canvas so we can reuse it later -/** @type {HTMLCanvasElement} */ -let lastCanvas = null; -/** @type {CanvasRenderingContext2D} */ -let lastContext = null; - -/** - * The core manages the root and represents the whole game. It wraps the root, since - * the root class is just a data holder. - */ -export class GameCore { - /** @param {Application} app */ - constructor(app) { - this.app = app; - - /** @type {GameRoot} */ - this.root = null; - - /** - * Set to true at the beginning of a logic update and cleared when its finished. - * This is to prevent doing a recursive logic update which can lead to unexpected - * behaviour. - */ - this.duringLogicUpdate = false; - - // Cached - this.boundInternalTick = this.updateLogic.bind(this); - - /** - * Opacity of the overview alpha - * @TODO Doesn't belong here - */ - this.overlayAlpha = 0; - } - - /** - * Initializes the root object which stores all game related data. The state - * is required as a back reference (used sometimes) - * @param {import("../states/ingame").InGameState} parentState - * @param {Savegame} savegame - */ - initializeRoot(parentState, savegame, gameModeId) { - logger.log("initializing root"); - - // Construct the root element, this is the data representation of the game - this.root = new GameRoot(this.app); - this.root.gameState = parentState; - this.root.keyMapper = parentState.keyActionMapper; - this.root.savegame = savegame; - this.root.gameWidth = this.app.screenWidth; - this.root.gameHeight = this.app.screenHeight; - - // Initialize canvas element & context - this.internalInitCanvas(); - - // Members - const root = this.root; - - // This isn't nice, but we need it right here - root.keyMapper = new KeyActionMapper(root, this.root.gameState.inputReciever); - - // Init game mode - root.gameMode = GameMode.create(root, gameModeId, parentState.creationPayload.gameModeParameters); - - // Needs to come first - root.dynamicTickrate = new DynamicTickrate(root); - - // Init classes - root.camera = new Camera(root); - root.map = new MapView(root); - 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); - - // Init managers - root.entityMgr = new EntityManager(root); - root.systemMgr = new GameSystemManager(root); - root.shapeDefinitionMgr = new ShapeDefinitionManager(root); - root.hubGoals = new HubGoals(root); - root.productionAnalytics = new ProductionAnalytics(root); - root.buffers = new BufferMaintainer(root); - - // Initialize the hud once everything is loaded - this.root.hud.initialize(); - - // Initial resize event, it might be possible that the screen - // resized later during init tho, which is why will emit it later - // again anyways - this.resize(this.app.screenWidth, this.app.screenHeight); - - if (G_IS_DEV) { - // @ts-ignore - window.globalRoot = root; - } - - // @todo Find better place - if (G_IS_DEV && globalConfig.debug.manualTickOnly) { - this.root.gameState.inputReciever.keydown.add(key => { - if (key.keyCode === 84) { - // 'T' - - // Extract current real time - this.root.time.updateRealtimeNow(); - - // Perform logic ticks - this.root.time.performTicks(this.root.dynamicTickrate.deltaMs, this.boundInternalTick); - - // Update analytics - root.productionAnalytics.update(); - - // Check achievements - root.achievementProxy.update(); - } - }); - } - - logger.log("root initialized"); - } - - /** - * Initializes a new game, this means creating a new map and centering on the - * playerbase - * */ - initNewGame() { - logger.log("Initializing new game"); - this.root.gameIsFresh = true; - this.root.map.seed = randomInt(0, 100000); - - if (!this.root.gameMode.hasHub()) { - return; - } - - // Place the hub - const hub = gMetaBuildingRegistry.findByClass(MetaHubBuilding).createEntity({ - root: this.root, - origin: new Vector(-2, -2), - rotation: 0, - originalRotation: 0, - rotationVariant: 0, - variant: defaultBuildingVariant, - }); - this.root.map.placeStaticEntity(hub); - this.root.entityMgr.registerEntity(hub); - } - - /** - * Inits an existing game by loading the raw savegame data and deserializing it. - * Also runs basic validity checks. - */ - initExistingGame() { - logger.log("Initializing existing game"); - const serializer = new SavegameSerializer(); - - try { - const status = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root); - if (!status.isGood()) { - logger.error("savegame-deserialize-failed:" + status.reason); - return false; - } - } catch (ex) { - logger.error("Exception during deserialization:", ex); - return false; - } - this.root.gameIsFresh = false; - return true; - } - - /** - * Initializes the render canvas - */ - internalInitCanvas() { - let canvas, context; - if (!lastCanvas) { - logger.log("Creating new canvas"); - canvas = document.createElement("canvas"); - canvas.id = "ingame_Canvas"; - canvas.setAttribute("opaque", "true"); - canvas.setAttribute("webkitOpaque", "true"); - canvas.setAttribute("mozOpaque", "true"); - this.root.gameState.getDivElement().appendChild(canvas); - context = canvas.getContext("2d", { alpha: false }); - - lastCanvas = canvas; - lastContext = context; - } else { - logger.log("Reusing canvas"); - if (lastCanvas.parentElement) { - lastCanvas.parentElement.removeChild(lastCanvas); - } - this.root.gameState.getDivElement().appendChild(lastCanvas); - - canvas = lastCanvas; - context = lastContext; - - lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height); - } - - canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas); - - // Oof, use :not() instead - canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas); - - if (globalConfig.smoothing.smoothMainCanvas) { - enableImageSmoothing(context); - } else { - disableImageSmoothing(context); - } - - this.root.canvas = canvas; - this.root.context = context; - - registerCanvas(canvas, context); - } - - /** - * Destructs the root, freeing all resources - */ - destruct() { - if (lastCanvas && lastCanvas.parentElement) { - lastCanvas.parentElement.removeChild(lastCanvas); - } - - this.root.destruct(); - delete this.root; - this.root = null; - this.app = null; - } - - tick(deltaMs) { - const root = this.root; - - // Extract current real time - root.time.updateRealtimeNow(); - - // Camera is always updated, no matter what - root.camera.update(deltaMs); - - if (!(G_IS_DEV && globalConfig.debug.manualTickOnly)) { - // Perform logic ticks - this.root.time.performTicks(deltaMs, this.boundInternalTick); - - // Update analytics - root.productionAnalytics.update(); - - // Check achievements - root.achievementProxy.update(); - } - - // Update automatic save after everything finished - root.automaticSave.update(); - - return true; - } - - shouldRender() { - if (this.root.queue.requireRedraw) { - return true; - } - if (this.root.hud.shouldPauseRendering()) { - return false; - } - - // Do not render - if (!this.app.isRenderable()) { - return false; - } - - return true; - } - - updateLogic() { - const root = this.root; - - root.dynamicTickrate.beginTick(); - - if (G_IS_DEV && globalConfig.debug.disableLogicTicks) { - root.dynamicTickrate.endTick(); - return true; - } - - this.duringLogicUpdate = true; - - // Update entities, this removes destroyed entities - root.entityMgr.update(); - - // IMPORTANT: At this point, the game might be game over. Stop if this is the case - if (!this.root) { - logger.log("Root destructed, returning false"); - root.dynamicTickrate.endTick(); - - return false; - } - - root.systemMgr.update(); - // root.particleMgr.update(); - - this.duringLogicUpdate = false; - root.dynamicTickrate.endTick(); - return true; - } - - resize(w, h) { - this.root.gameWidth = w; - this.root.gameHeight = h; - resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas); - this.root.signals.resized.dispatch(w, h); - this.root.queue.requireRedraw = true; - } - - postLoadHook() { - logger.log("Dispatching post load hook"); - this.root.signals.postLoadHook.dispatch(); - - if (!this.root.gameIsFresh) { - // Also dispatch game restored hook on restored savegames - this.root.signals.gameRestored.dispatch(); - } - - this.root.gameInitialized = true; - } - - draw() { - const root = this.root; - const systems = root.systemMgr.systems; - - this.root.dynamicTickrate.onFrameRendered(); - - if (!this.shouldRender()) { - // Always update hud tho - root.hud.update(); - return; - } - - this.root.signals.gameFrameStarted.dispatch(); - - root.queue.requireRedraw = false; - - // Gather context and save all state - const context = root.context; - context.save(); - if (G_IS_DEV) { - context.fillStyle = "#a10000"; - context.fillRect(0, 0, window.innerWidth * 3, window.innerHeight * 3); - } - - // Compute optimal zoom level and atlas scale - const zoomLevel = root.camera.zoomLevel; - const lowQuality = root.app.settings.getAllSettings().lowQualityTextures; - const effectiveZoomLevel = - (zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness; - - let desiredAtlasScale = "0.25"; - if (effectiveZoomLevel > 0.5 && !lowQuality) { - desiredAtlasScale = ORIGINAL_SPRITE_SCALE; - } else if (effectiveZoomLevel > 0.35 && !lowQuality) { - desiredAtlasScale = "0.5"; - } - - // Construct parameters required for drawing - const params = new DrawParameters({ - context: context, - visibleRect: root.camera.getVisibleRect(), - desiredAtlasScale, - zoomLevel, - root: root, - }); - - if (G_IS_DEV && globalConfig.debug.testCulling) { - context.clearRect(0, 0, root.gameWidth, root.gameHeight); - } - - // Transform to world space - - if (G_IS_DEV && globalConfig.debug.testClipping) { - params.visibleRect = params.visibleRect.expandedInAllDirections( - -200 / this.root.camera.zoomLevel - ); - } - - root.camera.transform(context); - - assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start"); - - // Update hud - root.hud.update(); - - // Main rendering order - // ----- - - const desiredOverlayAlpha = this.root.camera.getIsMapOverlayActive() ? 1 : 0; - this.overlayAlpha = lerp(this.overlayAlpha, desiredOverlayAlpha, 0.25); - - // On low performance, skip the fade - if (this.root.entityMgr.entities.length > 5000 || this.root.dynamicTickrate.averageFps < 50) { - this.overlayAlpha = desiredOverlayAlpha; - } - - if (this.overlayAlpha < 0.99) { - // Background (grid, resources, etc) - root.map.drawBackground(params); - - // Belt items - systems.belt.drawBeltItems(params); - - // Miner & Static map entities etc. - root.map.drawForeground(params); - - // HUB Overlay - systems.hub.draw(params); - - // Green wires overlay - if (root.hud.parts.wiresOverlay) { - root.hud.parts.wiresOverlay.draw(params); - } - - if (this.root.currentLayer === "wires") { - // Static map entities - root.map.drawWiresForegroundLayer(params); - } - } - - if (this.overlayAlpha > 0.01) { - // Map overview - context.globalAlpha = this.overlayAlpha; - root.map.drawOverlay(params); - context.globalAlpha = 1; - } - - if (G_IS_DEV) { - root.map.drawStaticEntityDebugOverlays(params); - } - - if (G_IS_DEV && globalConfig.debug.renderBeltPaths) { - systems.belt.drawBeltPathDebug(params); - } - - // END OF GAME CONTENT - // ----- - - // Finally, draw the hud. Nothing should come after that - root.hud.draw(params); - - assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore"); - - // Restore to screen space - context.restore(); - - // Restore parameters - params.zoomLevel = 1; - params.desiredAtlasScale = ORIGINAL_SPRITE_SCALE; - params.visibleRect = new Rectangle(0, 0, this.root.gameWidth, this.root.gameHeight); - if (G_IS_DEV && globalConfig.debug.testClipping) { - params.visibleRect = params.visibleRect.expandedInAllDirections(-200); - } - - // Draw overlays, those are screen space - root.hud.drawOverlays(params); - - assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end"); - - if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) { - let sum = 0; - for (let i = 0; i < 1e8; ++i) { - sum += i; - } - if (Math.random() > 0.95) { - console.log(sum); - } - } - - if (G_IS_DEV && globalConfig.debug.showAtlasInfo) { - context.font = "13px GameFont"; - context.fillStyle = "blue"; - context.fillText( - "Atlas: " + - desiredAtlasScale + - " / Zoom: " + - round2Digits(zoomLevel) + - " / Effective Zoom: " + - round2Digits(effectiveZoomLevel), - 20, - 600 - ); - - const stats = this.root.buffers.getStats(); - - context.fillText( - "Maintained Buffers: " + - stats.rootKeys + - " root keys / " + - stats.subKeys + - " buffers / VRAM: " + - round2Digits(stats.vramBytes / (1024 * 1024)) + - " MB", - 20, - 620 - ); - const internalStats = getBufferStats(); - context.fillText( - "Total Buffers: " + - internalStats.bufferCount + - " buffers / " + - internalStats.backlogSize + - " backlog / " + - internalStats.backlogKeys + - " keys in backlog / VRAM " + - round2Digits(internalStats.vramUsage / (1024 * 1024)) + - " MB / Backlog " + - round2Digits(internalStats.backlogVramUsage / (1024 * 1024)) + - " MB / Created " + - internalStats.numCreated + - " / Reused " + - internalStats.numReused, - 20, - 640 - ); - } - - if (G_IS_DEV && globalConfig.debug.testClipping) { - context.strokeStyle = "red"; - context.lineWidth = 1; - context.beginPath(); - context.rect(200, 200, this.root.gameWidth - 400, this.root.gameHeight - 400); - context.stroke(); - } - } -} +/* typehints:start */ +import { Application } from "../application"; +/* typehints:end */ +import { BufferMaintainer } from "../core/buffer_maintainer"; +import { + disableImageSmoothing, + enableImageSmoothing, + getBufferStats, + registerCanvas, +} from "../core/buffer_utils"; +import { globalConfig } from "../core/config"; +import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager"; +import { DrawParameters } from "../core/draw_parameters"; +import { gMetaBuildingRegistry } from "../core/global_registries"; +import { createLogger } from "../core/logging"; +import { Rectangle } from "../core/rectangle"; +import { ORIGINAL_SPRITE_SCALE } from "../core/sprites"; +import { lerp, randomInt, round2Digits } from "../core/utils"; +import { Vector } from "../core/vector"; +import { Savegame } from "../savegame/savegame"; +import { SavegameSerializer } from "../savegame/savegame_serializer"; +import { AutomaticSave } from "./automatic_save"; +import { MetaHubBuilding } from "./buildings/hub"; +import { Camera } from "./camera"; +import { DynamicTickrate } from "./dynamic_tickrate"; +import { EntityManager } from "./entity_manager"; +import { GameSystemManager } from "./game_system_manager"; +import { HubGoals } from "./hub_goals"; +import { GameHUD } from "./hud/hud"; +import { KeyActionMapper } from "./key_action_mapper"; +import { GameLogic } from "./logic"; +import { MapView } from "./map_view"; +import { defaultBuildingVariant } from "./meta_building"; +import { GameMode } from "./game_mode"; +import { ProductionAnalytics } from "./production_analytics"; +import { GameRoot } from "./root"; +import { ShapeDefinitionManager } from "./shape_definition_manager"; +import { AchievementProxy } from "./achievement_proxy"; +import { SoundProxy } from "./sound_proxy"; +import { GameTime } from "./time/game_time"; + +const logger = createLogger("ingame/core"); + +// Store the canvas so we can reuse it later +/** @type {HTMLCanvasElement} */ +let lastCanvas = null; +/** @type {CanvasRenderingContext2D} */ +let lastContext = null; + +/** + * The core manages the root and represents the whole game. It wraps the root, since + * the root class is just a data holder. + */ +export class GameCore { + /** @param {Application} app */ + constructor(app) { + this.app = app; + + /** @type {GameRoot} */ + this.root = null; + + /** + * Set to true at the beginning of a logic update and cleared when its finished. + * This is to prevent doing a recursive logic update which can lead to unexpected + * behaviour. + */ + this.duringLogicUpdate = false; + + // Cached + this.boundInternalTick = this.updateLogic.bind(this); + + /** + * Opacity of the overview alpha + * @TODO Doesn't belong here + */ + this.overlayAlpha = 0; + } + + /** + * Initializes the root object which stores all game related data. The state + * is required as a back reference (used sometimes) + * @param {import("../states/ingame").InGameState} parentState + * @param {Savegame} savegame + */ + initializeRoot(parentState, savegame, gameModeId) { + logger.log("initializing root"); + + // Construct the root element, this is the data representation of the game + this.root = new GameRoot(this.app); + this.root.gameState = parentState; + this.root.keyMapper = parentState.keyActionMapper; + this.root.savegame = savegame; + this.root.gameWidth = this.app.screenWidth; + this.root.gameHeight = this.app.screenHeight; + + // Initialize canvas element & context + this.internalInitCanvas(); + + // Members + const root = this.root; + + // This isn't nice, but we need it right here + root.keyMapper = new KeyActionMapper(root, this.root.gameState.inputReciever); + + // Init game mode + root.gameMode = GameMode.create(root, gameModeId, parentState.creationPayload.gameModeParameters); + + // Needs to come first + root.dynamicTickrate = new DynamicTickrate(root); + + // Init classes + root.camera = new Camera(root); + root.map = new MapView(root); + 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); + + // Init managers + root.entityMgr = new EntityManager(root); + root.systemMgr = new GameSystemManager(root); + root.shapeDefinitionMgr = new ShapeDefinitionManager(root); + root.hubGoals = new HubGoals(root); + root.productionAnalytics = new ProductionAnalytics(root); + root.buffers = new BufferMaintainer(root); + + // Initialize the hud once everything is loaded + this.root.hud.initialize(); + + // Initial resize event, it might be possible that the screen + // resized later during init tho, which is why will emit it later + // again anyways + this.resize(this.app.screenWidth, this.app.screenHeight); + + if (G_IS_DEV) { + // @ts-ignore + window.globalRoot = root; + } + + // @todo Find better place + if (G_IS_DEV && globalConfig.debug.manualTickOnly) { + this.root.gameState.inputReciever.keydown.add(key => { + if (key.keyCode === 84) { + // 'T' + + // Extract current real time + this.root.time.updateRealtimeNow(); + + // Perform logic ticks + this.root.time.performTicks(this.root.dynamicTickrate.deltaMs, this.boundInternalTick); + + // Update analytics + root.productionAnalytics.update(); + + // Check achievements + root.achievementProxy.update(); + } + }); + } + + logger.log("root initialized"); + } + + /** + * Initializes a new game, this means creating a new map and centering on the + * playerbase + * + * @param {object} param1 + * @param {number} param1.seed + * @param {boolean} param1.allowNonPrimaryColors + * @param {number} param1.fullShapePercentage + * @param {number} param1.wierdShapePercentage + * */ + initNewGame({ seed, allowNonPrimaryColors, fullShapePercentage, wierdShapePercentage }) { + logger.log("Initializing new game"); + this.root.gameIsFresh = true; + this.root.map.seed = seed; + this.root.map.allowNonPrimaryColors = allowNonPrimaryColors; + this.root.map.fullShapePercentage = fullShapePercentage; + this.root.map.wierdShapePercentage = wierdShapePercentage; + + logger.log("Initializing newGame with seed: ", this.root.map.seed); + + if (!this.root.gameMode.hasHub()) { + return; + } + + // Place the hub + const hub = gMetaBuildingRegistry.findByClass(MetaHubBuilding).createEntity({ + root: this.root, + origin: new Vector(-2, -2), + rotation: 0, + originalRotation: 0, + rotationVariant: 0, + variant: defaultBuildingVariant, + }); + this.root.map.placeStaticEntity(hub); + this.root.entityMgr.registerEntity(hub); + } + + /** + * Inits an existing game by loading the raw savegame data and deserializing it. + * Also runs basic validity checks. + */ + initExistingGame() { + logger.log("Initializing existing game"); + const serializer = new SavegameSerializer(); + + try { + const status = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root); + if (!status.isGood()) { + logger.error("savegame-deserialize-failed:" + status.reason); + return false; + } + } catch (ex) { + logger.error("Exception during deserialization:", ex); + return false; + } + this.root.gameIsFresh = false; + logger.log("Initializing existingGame with seed: ", this.root.map.seed); + return true; + } + + /** + * Initializes the render canvas + */ + internalInitCanvas() { + let canvas, context; + if (!lastCanvas) { + logger.log("Creating new canvas"); + canvas = document.createElement("canvas"); + canvas.id = "ingame_Canvas"; + canvas.setAttribute("opaque", "true"); + canvas.setAttribute("webkitOpaque", "true"); + canvas.setAttribute("mozOpaque", "true"); + this.root.gameState.getDivElement().appendChild(canvas); + context = canvas.getContext("2d", { alpha: false }); + + lastCanvas = canvas; + lastContext = context; + } else { + logger.log("Reusing canvas"); + if (lastCanvas.parentElement) { + lastCanvas.parentElement.removeChild(lastCanvas); + } + this.root.gameState.getDivElement().appendChild(lastCanvas); + + canvas = lastCanvas; + context = lastContext; + + lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height); + } + + canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas); + + // Oof, use :not() instead + canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas); + + if (globalConfig.smoothing.smoothMainCanvas) { + enableImageSmoothing(context); + } else { + disableImageSmoothing(context); + } + + this.root.canvas = canvas; + this.root.context = context; + + registerCanvas(canvas, context); + } + + /** + * Destructs the root, freeing all resources + */ + destruct() { + if (lastCanvas && lastCanvas.parentElement) { + lastCanvas.parentElement.removeChild(lastCanvas); + } + + this.root.destruct(); + delete this.root; + this.root = null; + this.app = null; + } + + tick(deltaMs) { + const root = this.root; + + // Extract current real time + root.time.updateRealtimeNow(); + + // Camera is always updated, no matter what + root.camera.update(deltaMs); + + if (!(G_IS_DEV && globalConfig.debug.manualTickOnly)) { + // Perform logic ticks + this.root.time.performTicks(deltaMs, this.boundInternalTick); + + // Update analytics + root.productionAnalytics.update(); + + // Check achievements + root.achievementProxy.update(); + } + + // Update automatic save after everything finished + root.automaticSave.update(); + + return true; + } + + shouldRender() { + if (this.root.queue.requireRedraw) { + return true; + } + if (this.root.hud.shouldPauseRendering()) { + return false; + } + + // Do not render + if (!this.app.isRenderable()) { + return false; + } + + return true; + } + + updateLogic() { + const root = this.root; + + root.dynamicTickrate.beginTick(); + + if (G_IS_DEV && globalConfig.debug.disableLogicTicks) { + root.dynamicTickrate.endTick(); + return true; + } + + this.duringLogicUpdate = true; + + // Update entities, this removes destroyed entities + root.entityMgr.update(); + + // IMPORTANT: At this point, the game might be game over. Stop if this is the case + if (!this.root) { + logger.log("Root destructed, returning false"); + root.dynamicTickrate.endTick(); + + return false; + } + + root.systemMgr.update(); + // root.particleMgr.update(); + + this.duringLogicUpdate = false; + root.dynamicTickrate.endTick(); + return true; + } + + resize(w, h) { + this.root.gameWidth = w; + this.root.gameHeight = h; + resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas); + this.root.signals.resized.dispatch(w, h); + this.root.queue.requireRedraw = true; + } + + postLoadHook() { + logger.log("Dispatching post load hook"); + this.root.signals.postLoadHook.dispatch(); + + if (!this.root.gameIsFresh) { + // Also dispatch game restored hook on restored savegames + this.root.signals.gameRestored.dispatch(); + } + + this.root.gameInitialized = true; + } + + draw() { + const root = this.root; + const systems = root.systemMgr.systems; + + this.root.dynamicTickrate.onFrameRendered(); + + if (!this.shouldRender()) { + // Always update hud tho + root.hud.update(); + return; + } + + this.root.signals.gameFrameStarted.dispatch(); + + root.queue.requireRedraw = false; + + // Gather context and save all state + const context = root.context; + context.save(); + if (G_IS_DEV) { + context.fillStyle = "#a10000"; + context.fillRect(0, 0, window.innerWidth * 3, window.innerHeight * 3); + } + + // Compute optimal zoom level and atlas scale + const zoomLevel = root.camera.zoomLevel; + const lowQuality = root.app.settings.getAllSettings().lowQualityTextures; + const effectiveZoomLevel = + (zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness; + + let desiredAtlasScale = "0.25"; + if (effectiveZoomLevel > 0.5 && !lowQuality) { + desiredAtlasScale = ORIGINAL_SPRITE_SCALE; + } else if (effectiveZoomLevel > 0.35 && !lowQuality) { + desiredAtlasScale = "0.5"; + } + + // Construct parameters required for drawing + const params = new DrawParameters({ + context: context, + visibleRect: root.camera.getVisibleRect(), + desiredAtlasScale, + zoomLevel, + root: root, + }); + + if (G_IS_DEV && globalConfig.debug.testCulling) { + context.clearRect(0, 0, root.gameWidth, root.gameHeight); + } + + // Transform to world space + + if (G_IS_DEV && globalConfig.debug.testClipping) { + params.visibleRect = params.visibleRect.expandedInAllDirections( + -200 / this.root.camera.zoomLevel + ); + } + + root.camera.transform(context); + + assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start"); + + // Update hud + root.hud.update(); + + // Main rendering order + // ----- + + const desiredOverlayAlpha = this.root.camera.getIsMapOverlayActive() ? 1 : 0; + this.overlayAlpha = lerp(this.overlayAlpha, desiredOverlayAlpha, 0.25); + + // On low performance, skip the fade + if (this.root.entityMgr.entities.length > 5000 || this.root.dynamicTickrate.averageFps < 50) { + this.overlayAlpha = desiredOverlayAlpha; + } + + if (this.overlayAlpha < 0.99) { + // Background (grid, resources, etc) + root.map.drawBackground(params); + + // Belt items + systems.belt.drawBeltItems(params); + + // Miner & Static map entities etc. + root.map.drawForeground(params); + + // HUB Overlay + systems.hub.draw(params); + + // Green wires overlay + if (root.hud.parts.wiresOverlay) { + root.hud.parts.wiresOverlay.draw(params); + } + + if (this.root.currentLayer === "wires") { + // Static map entities + root.map.drawWiresForegroundLayer(params); + } + } + + if (this.overlayAlpha > 0.01) { + // Map overview + context.globalAlpha = this.overlayAlpha; + root.map.drawOverlay(params); + context.globalAlpha = 1; + } + + if (G_IS_DEV) { + root.map.drawStaticEntityDebugOverlays(params); + } + + if (G_IS_DEV && globalConfig.debug.renderBeltPaths) { + systems.belt.drawBeltPathDebug(params); + } + + // END OF GAME CONTENT + // ----- + + // Finally, draw the hud. Nothing should come after that + root.hud.draw(params); + + assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore"); + + // Restore to screen space + context.restore(); + + // Restore parameters + params.zoomLevel = 1; + params.desiredAtlasScale = ORIGINAL_SPRITE_SCALE; + params.visibleRect = new Rectangle(0, 0, this.root.gameWidth, this.root.gameHeight); + if (G_IS_DEV && globalConfig.debug.testClipping) { + params.visibleRect = params.visibleRect.expandedInAllDirections(-200); + } + + // Draw overlays, those are screen space + root.hud.drawOverlays(params); + + assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end"); + + if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) { + let sum = 0; + for (let i = 0; i < 1e8; ++i) { + sum += i; + } + if (Math.random() > 0.95) { + console.log(sum); + } + } + + if (G_IS_DEV && globalConfig.debug.showAtlasInfo) { + context.font = "13px GameFont"; + context.fillStyle = "blue"; + context.fillText( + "Atlas: " + + desiredAtlasScale + + " / Zoom: " + + round2Digits(zoomLevel) + + " / Effective Zoom: " + + round2Digits(effectiveZoomLevel), + 20, + 600 + ); + + const stats = this.root.buffers.getStats(); + + context.fillText( + "Maintained Buffers: " + + stats.rootKeys + + " root keys / " + + stats.subKeys + + " buffers / VRAM: " + + round2Digits(stats.vramBytes / (1024 * 1024)) + + " MB", + 20, + 620 + ); + const internalStats = getBufferStats(); + context.fillText( + "Total Buffers: " + + internalStats.bufferCount + + " buffers / " + + internalStats.backlogSize + + " backlog / " + + internalStats.backlogKeys + + " keys in backlog / VRAM " + + round2Digits(internalStats.vramUsage / (1024 * 1024)) + + " MB / Backlog " + + round2Digits(internalStats.backlogVramUsage / (1024 * 1024)) + + " MB / Created " + + internalStats.numCreated + + " / Reused " + + internalStats.numReused, + 20, + 640 + ); + } + + if (G_IS_DEV && globalConfig.debug.testClipping) { + context.strokeStyle = "red"; + context.lineWidth = 1; + context.beginPath(); + context.rect(200, 200, this.root.gameWidth - 400, this.root.gameHeight - 400); + context.stroke(); + } + } +} diff --git a/src/js/game/map.js b/src/js/game/map.js index 67df7db3..c59341b9 100644 --- a/src/js/game/map.js +++ b/src/js/game/map.js @@ -15,6 +15,9 @@ export class BaseMap extends BasicSerializableObject { static getSchema() { return { seed: types.uint, + allowNonPrimaryColors: types.bool, + fullShapePercentage: types.uint, + wierdShapePercentage: types.uint, }; } @@ -27,6 +30,9 @@ export class BaseMap extends BasicSerializableObject { this.root = root; this.seed = 0; + this.allowNonPrimaryColors = false; + this.fullShapePercentage = 0; + this.wierdShapePercentage = 0; /** * Mapping of 'X|Y' to chunk diff --git a/src/js/game/map_chunk.js b/src/js/game/map_chunk.js index 54af1125..00d41408 100644 --- a/src/js/game/map_chunk.js +++ b/src/js/game/map_chunk.js @@ -168,6 +168,11 @@ export class MapChunk { let availableColors = [enumColors.red, enumColors.green]; if (distanceToOriginInChunks > 2) { availableColors.push(enumColors.blue); + if (this.root.map.allowNonPrimaryColors) { + availableColors.push(enumColors.yellow); + availableColors.push(enumColors.purple); + availableColors.push(enumColors.cyan); + } } this.internalGeneratePatch(rng, colorPatchSize, COLOR_ITEM_SINGLETONS[rng.choice(availableColors)]); } @@ -198,7 +203,19 @@ export class MapChunk { weights[enumSubShape.windmill] = 0; } - if (distanceToOriginInChunks < 10) { + if (rng.nextRange(0, 100) <= this.root.map.fullShapePercentage) { + // Spawn full shape based on percentage. + const subShape = this.internalGenerateRandomSubShape(rng, weights); + subShapes = [subShape, subShape, subShape, subShape]; + } else if (rng.nextRange(0, 100) <= this.root.map.wierdShapePercentage) { + // Spawn wierd shape based on percentage. + subShapes = [ + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + ]; + } else if (distanceToOriginInChunks < 10) { // Initial chunk patches always have the same shape const subShape = this.internalGenerateRandomSubShape(rng, weights); subShapes = [subShape, subShape, subShape, subShape]; diff --git a/src/js/game/themes/dark.json b/src/js/game/themes/dark.json index fec42e28..d943544f 100644 --- a/src/js/game/themes/dark.json +++ b/src/js/game/themes/dark.json @@ -28,7 +28,10 @@ "shape": "#5d5f6a", "red": "#854f56", "green": "#667964", - "blue": "#5e7ca4" + "blue": "#5e7ca4", + "purple": "#8776bc", + "yellow": "#cab57d", + "cyan": "#00b5b8" }, "chunkOverview": { "empty": "#444856", diff --git a/src/js/game/themes/light.json b/src/js/game/themes/light.json index d44a15ab..4f064536 100644 --- a/src/js/game/themes/light.json +++ b/src/js/game/themes/light.json @@ -28,7 +28,10 @@ "shape": "#eaebec", "red": "#ffbfc1", "green": "#cbffc4", - "blue": "#bfdaff" + "blue": "#bfdaff", + "purple": "#ecb3fc", + "yellow": "#fcf99c", + "cyan": "#85fdff" }, "chunkOverview": { diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index 36ed884f..3a6893ff 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -14,6 +14,7 @@ import { SavegameInterface_V1006 } from "./schemas/1006"; import { SavegameInterface_V1007 } from "./schemas/1007"; import { SavegameInterface_V1008 } from "./schemas/1008"; import { SavegameInterface_V1009 } from "./schemas/1009"; +import { SavegameInterface_V1010 } from "./schemas/1010"; const logger = createLogger("savegame"); @@ -54,7 +55,7 @@ export class Savegame extends ReadWriteProxy { * @returns {number} */ static getCurrentVersion() { - return 1009; + return 1010; } /** @@ -160,6 +161,11 @@ export class Savegame extends ReadWriteProxy { data.version = 1009; } + if (data.version === 1009) { + SavegameInterface_V1010.migrate1009to1010(data); + data.version = 1010; + } + return ExplainedResult.good(); } diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js index b4dc4233..089b15fc 100644 --- a/src/js/savegame/savegame_interface_registry.js +++ b/src/js/savegame/savegame_interface_registry.js @@ -10,6 +10,7 @@ import { SavegameInterface_V1006 } from "./schemas/1006"; import { SavegameInterface_V1007 } from "./schemas/1007"; import { SavegameInterface_V1008 } from "./schemas/1008"; import { SavegameInterface_V1009 } from "./schemas/1009"; +import { SavegameInterface_V1010 } from "./schemas/1010"; /** @type {Object.} */ export const savegameInterfaces = { @@ -23,6 +24,7 @@ export const savegameInterfaces = { 1007: SavegameInterface_V1007, 1008: SavegameInterface_V1008, 1009: SavegameInterface_V1009, + 1010: SavegameInterface_V1010, }; const logger = createLogger("savegame_interface_registry"); diff --git a/src/js/savegame/schemas/1010.js b/src/js/savegame/schemas/1010.js new file mode 100644 index 00000000..56cca297 --- /dev/null +++ b/src/js/savegame/schemas/1010.js @@ -0,0 +1,38 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1009 } from "./1009.js"; + +const schema = require("./1010.json"); +const logger = createLogger("savegame_interface/1010"); + +export class SavegameInterface_V1010 extends SavegameInterface_V1009 { + getVersion() { + return 1010; + } + + getSchemaUncached() { + return schema; + } + + /** + * @param {import("../savegame_typedefs.js").SavegameData} data + */ + static migrate1009to1010(data) { + logger.log("Migrating 1009 to 1010"); + const dump = data.dump; + if (!dump) { + return true; + } + + if (!dump.map.hasOwnProperty("allowNonPrimaryColors")) { + dump.map.allowNonPrimaryColors = false; + } + + if (!dump.map.hasOwnProperty("fullShapePercentage")) { + dump.map.fullShapePercentage = 0; + } + + if (!dump.map.hasOwnProperty("wierdShapePercentage")) { + dump.map.wierdShapePercentage = 0; + } + } +} diff --git a/src/js/savegame/schemas/1010.json b/src/js/savegame/schemas/1010.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/js/savegame/schemas/1010.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js index 0dd6c72a..220f31ea 100644 --- a/src/js/states/ingame.js +++ b/src/js/states/ingame.js @@ -1,480 +1,513 @@ -import { APPLICATION_ERROR_OCCURED } from "../core/error_handler"; -import { GameState } from "../core/game_state"; -import { logSection, createLogger } from "../core/logging"; -import { waitNextFrame } from "../core/utils"; -import { globalConfig } from "../core/config"; -import { GameLoadingOverlay } from "../game/game_loading_overlay"; -import { KeyActionMapper } from "../game/key_action_mapper"; -import { Savegame } from "../savegame/savegame"; -import { GameCore } from "../game/core"; -import { MUSIC } from "../platform/sound"; -import { enumGameModeIds } from "../game/game_mode"; - -const logger = createLogger("state/ingame"); - -// Different sub-states -const stages = { - s3_createCore: "🌈 3: Create core", - s4_A_initEmptyGame: "🌈 4/A: Init empty game", - s4_B_resumeGame: "🌈 4/B: Resume game", - - s5_firstUpdate: "🌈 5: First game update", - s6_postLoadHook: "🌈 6: Post load hook", - s7_warmup: "🌈 7: Warmup", - - s10_gameRunning: "🌈 10: Game finally running", - - leaving: "🌈 Saving, then leaving the game", - destroyed: "🌈 DESTROYED: Core is empty and waits for state leave", - initFailed: "🌈 ERROR: Initialization failed!", -}; - -export const gameCreationAction = { - new: "new-game", - resume: "resume-game", -}; - -// Typehints -export class GameCreationPayload { - constructor() { - /** @type {boolean|undefined} */ - this.fastEnter; - - /** @type {string} */ - this.gameModeId; - - /** @type {Savegame} */ - this.savegame; - - /** @type {object|undefined} */ - this.gameModeParameters; - } -} - -export class InGameState extends GameState { - constructor() { - super("InGameState"); - - /** @type {GameCreationPayload} */ - this.creationPayload = null; - - // Stores current stage - this.stage = ""; - - /** @type {GameCore} */ - this.core = null; - - /** @type {KeyActionMapper} */ - this.keyActionMapper = null; - - /** @type {GameLoadingOverlay} */ - this.loadingOverlay = null; - - /** @type {Savegame} */ - this.savegame = null; - - this.boundInputFilter = this.filterInput.bind(this); - - /** - * Whether we are currently saving the game - * @TODO: This doesn't realy fit here - */ - this.currentSavePromise = null; - } - - /** - * Switches the game into another sub-state - * @param {string} stage - */ - switchStage(stage) { - assert(stage, "Got empty stage"); - if (stage !== this.stage) { - this.stage = stage; - logger.log(this.stage); - return true; - } else { - // log(this, "Re entering", stage); - return false; - } - } - - // GameState implementation - getInnerHTML() { - return ""; - } - - getThemeMusic() { - if (this.creationPayload.gameModeId && this.creationPayload.gameModeId.includes("puzzle")) { - return MUSIC.puzzle; - } - return MUSIC.theme; - } - - onBeforeExit() { - // logger.log("Saving before quitting"); - // return this.doSave().then(() => { - // logger.log(this, "Successfully saved"); - // // this.stageDestroyed(); - // }); - } - - onAppPause() { - // if (this.stage === stages.s10_gameRunning) { - // logger.log("Saving because app got paused"); - // this.doSave(); - // } - } - - getHasFadeIn() { - return false; - } - - getPauseOnFocusLost() { - return false; - } - - getHasUnloadConfirmation() { - return true; - } - - onLeave() { - if (this.core) { - this.stageDestroyed(); - } - this.app.inputMgr.dismountFilter(this.boundInputFilter); - } - - onResized(w, h) { - super.onResized(w, h); - if (this.stage === stages.s10_gameRunning) { - this.core.resize(w, h); - } - } - - // ---- End of GameState implementation - - /** - * Goes back to the menu state - */ - goBackToMenu() { - if ([enumGameModeIds.puzzleEdit, enumGameModeIds.puzzlePlay].includes(this.gameModeId)) { - this.saveThenGoToState("PuzzleMenuState"); - } else { - this.saveThenGoToState("MainMenuState"); - } - } - - /** - * Goes back to the settings state - */ - goToSettings() { - this.saveThenGoToState("SettingsState", { - backToStateId: this.key, - backToStatePayload: this.creationPayload, - }); - } - - /** - * Goes back to the settings state - */ - goToKeybindings() { - this.saveThenGoToState("KeybindingsState", { - backToStateId: this.key, - backToStatePayload: this.creationPayload, - }); - } - - /** - * Moves to a state outside of the game - * @param {string} stateId - * @param {any=} payload - */ - saveThenGoToState(stateId, payload) { - if (this.stage === stages.leaving || this.stage === stages.destroyed) { - logger.warn( - "Tried to leave game twice or during destroy:", - this.stage, - "(attempted to move to", - stateId, - ")" - ); - return; - } - this.stageLeavingGame(); - this.doSave().then(() => { - this.stageDestroyed(); - this.moveToState(stateId, payload); - }); - } - - onBackButton() { - // do nothing - } - - /** - * Called when the game somehow failed to initialize. Resets everything to basic state and - * then goes to the main menu, showing the error - * @param {string} err - */ - onInitializationFailure(err) { - if (this.switchStage(stages.initFailed)) { - logger.error("Init failure:", err); - this.stageDestroyed(); - this.moveToState("MainMenuState", { loadError: err }); - } - } - - // STAGES - - /** - * Creates the game core instance, and thus the root - */ - stage3CreateCore() { - if (this.switchStage(stages.s3_createCore)) { - logger.log("Creating new game core"); - this.core = new GameCore(this.app); - - this.core.initializeRoot(this, this.savegame, this.gameModeId); - - if (this.savegame.hasGameDump()) { - this.stage4bResumeGame(); - } else { - this.app.gameAnalytics.handleGameStarted(); - this.stage4aInitEmptyGame(); - } - } - } - - /** - * Initializes a new empty game - */ - stage4aInitEmptyGame() { - if (this.switchStage(stages.s4_A_initEmptyGame)) { - this.core.initNewGame(); - this.stage5FirstUpdate(); - } - } - - /** - * Resumes an existing game - */ - stage4bResumeGame() { - if (this.switchStage(stages.s4_B_resumeGame)) { - if (!this.core.initExistingGame()) { - this.onInitializationFailure("Savegame is corrupt and can not be restored."); - return; - } - this.app.gameAnalytics.handleGameResumed(); - this.stage5FirstUpdate(); - } - } - - /** - * Performs the first game update on the game which initializes most caches - */ - stage5FirstUpdate() { - if (this.switchStage(stages.s5_firstUpdate)) { - this.core.root.logicInitialized = true; - this.core.updateLogic(); - this.stage6PostLoadHook(); - } - } - - /** - * Call the post load hook, this means that we have loaded the game, and all systems - * can operate and start to work now. - */ - stage6PostLoadHook() { - if (this.switchStage(stages.s6_postLoadHook)) { - logger.log("Post load hook"); - this.core.postLoadHook(); - this.stage7Warmup(); - } - } - - /** - * This makes the game idle and draw for a while, because we run most code this way - * the V8 engine can already start to optimize it. Also this makes sure the resources - * are in the VRAM and we have a smooth experience once we start. - */ - stage7Warmup() { - if (this.switchStage(stages.s7_warmup)) { - if (this.creationPayload.fastEnter) { - this.warmupTimeSeconds = globalConfig.warmupTimeSecondsFast; - } else { - this.warmupTimeSeconds = globalConfig.warmupTimeSecondsRegular; - } - } - } - - /** - * The final stage where this game is running and updating regulary. - */ - stage10GameRunning() { - if (this.switchStage(stages.s10_gameRunning)) { - this.core.root.signals.readyToRender.dispatch(); - - logSection("GAME STARTED", "#26a69a"); - - // Initial resize, might have changed during loading (this is possible) - this.core.resize(this.app.screenWidth, this.app.screenHeight); - } - } - - /** - * This stage destroys the whole game, used to cleanup - */ - stageDestroyed() { - if (this.switchStage(stages.destroyed)) { - // Cleanup all api calls - this.cancelAllAsyncOperations(); - - if (this.syncer) { - this.syncer.cancelSync(); - this.syncer = null; - } - - // Cleanup core - if (this.core) { - this.core.destruct(); - this.core = null; - } - } - } - - /** - * When leaving the game - */ - stageLeavingGame() { - if (this.switchStage(stages.leaving)) { - // ... - } - } - - // END STAGES - - /** - * Filters the input (keybindings) - */ - filterInput() { - return this.stage === stages.s10_gameRunning; - } - - /** - * @param {GameCreationPayload} payload - */ - onEnter(payload) { - this.app.inputMgr.installFilter(this.boundInputFilter); - - this.creationPayload = payload; - this.savegame = payload.savegame; - this.gameModeId = payload.gameModeId; - - this.loadingOverlay = new GameLoadingOverlay(this.app, this.getDivElement()); - this.loadingOverlay.showBasic(); - - // Remove unneded default element - document.body.querySelector(".modalDialogParent").remove(); - - this.asyncChannel - .watch(waitNextFrame()) - .then(() => this.stage3CreateCore()) - .catch(ex => { - logger.error(ex); - throw ex; - }); - } - - /** - * Render callback - * @param {number} dt - */ - onRender(dt) { - if (APPLICATION_ERROR_OCCURED) { - // Application somehow crashed, do not do anything - return; - } - - if (this.stage === stages.s7_warmup) { - this.core.draw(); - this.warmupTimeSeconds -= dt / 1000.0; - if (this.warmupTimeSeconds < 0) { - logger.log("Warmup completed"); - this.stage10GameRunning(); - } - } - - if (this.stage === stages.s10_gameRunning) { - this.core.tick(dt); - } - - // If the stage is still active (This might not be the case if tick() moved us to game over) - if (this.stage === stages.s10_gameRunning) { - // Only draw if page visible - if (this.app.pageVisible) { - this.core.draw(); - } - - this.loadingOverlay.removeIfAttached(); - } else { - if (!this.loadingOverlay.isAttached()) { - this.loadingOverlay.showBasic(); - } - } - } - - onBackgroundTick(dt) { - this.onRender(dt); - } - - /** - * Saves the game - */ - - doSave() { - if (!this.savegame || !this.savegame.isSaveable()) { - return Promise.resolve(); - } - - if (APPLICATION_ERROR_OCCURED) { - logger.warn("skipping save because application crashed"); - return Promise.resolve(); - } - - if ( - this.stage !== stages.s10_gameRunning && - this.stage !== stages.s7_warmup && - this.stage !== stages.leaving - ) { - logger.warn("Skipping save because game is not ready"); - return Promise.resolve(); - } - - if (this.currentSavePromise) { - logger.warn("Skipping double save and returning same promise"); - return this.currentSavePromise; - } - - if (!this.core.root.gameMode.getIsSaveable()) { - return Promise.resolve(); - } - - logger.log("Starting to save game ..."); - this.savegame.updateData(this.core.root); - - this.currentSavePromise = this.savegame - .writeSavegameAndMetadata() - .catch(err => { - // Catch errors - logger.warn("Failed to save:", err); - }) - .then(() => { - // Clear promise - logger.log("Saved!"); - this.core.root.signals.gameSaved.dispatch(); - this.currentSavePromise = null; - }); - - return this.currentSavePromise; - } -} +import { APPLICATION_ERROR_OCCURED } from "../core/error_handler"; +import { GameState } from "../core/game_state"; +import { logSection, createLogger } from "../core/logging"; +import { waitNextFrame } from "../core/utils"; +import { globalConfig } from "../core/config"; +import { GameLoadingOverlay } from "../game/game_loading_overlay"; +import { KeyActionMapper } from "../game/key_action_mapper"; +import { Savegame } from "../savegame/savegame"; +import { GameCore } from "../game/core"; +import { MUSIC } from "../platform/sound"; +import { enumGameModeIds } from "../game/game_mode"; + +const logger = createLogger("state/ingame"); + +// Different sub-states +const stages = { + s3_createCore: "🌈 3: Create core", + s4_A_initEmptyGame: "🌈 4/A: Init empty game", + s4_B_resumeGame: "🌈 4/B: Resume game", + + s5_firstUpdate: "🌈 5: First game update", + s6_postLoadHook: "🌈 6: Post load hook", + s7_warmup: "🌈 7: Warmup", + + s10_gameRunning: "🌈 10: Game finally running", + + leaving: "🌈 Saving, then leaving the game", + destroyed: "🌈 DESTROYED: Core is empty and waits for state leave", + initFailed: "🌈 ERROR: Initialization failed!", +}; + +export const gameCreationAction = { + new: "new-game", + resume: "resume-game", +}; + +// Typehints +export class GameCreationPayload { + constructor() { + /** @type {boolean|undefined} */ + this.fastEnter; + + /** @type {string} */ + this.gameModeId; + + /** @type {Savegame} */ + this.savegame; + + /** @type {object|undefined} */ + this.gameModeParameters; + + /** @type {number} */ + this.seed; + + /** @type {boolean} */ + this.allowNonPrimaryColors; + + /** @type {number} */ + this.fullShapePercentage; + + /** @type {number} */ + this.wierdShapePercentage; + } +} + +export class InGameState extends GameState { + constructor() { + super("InGameState"); + + /** @type {GameCreationPayload} */ + this.creationPayload = null; + + // Stores current stage + this.stage = ""; + + /** @type {GameCore} */ + this.core = null; + + /** @type {KeyActionMapper} */ + this.keyActionMapper = null; + + /** @type {GameLoadingOverlay} */ + this.loadingOverlay = null; + + /** @type {Savegame} */ + this.savegame = null; + + /** @type {number} */ + this.seed = null; + + /** @type {boolean} */ + this.allowNonPrimaryColors = null; + + /** @type {number} */ + this.fullShapePercentage = null; + + /** @type {number} */ + this.wierdShapePercentage = null; + + this.boundInputFilter = this.filterInput.bind(this); + + /** + * Whether we are currently saving the game + * @TODO: This doesn't realy fit here + */ + this.currentSavePromise = null; + } + + /** + * Switches the game into another sub-state + * @param {string} stage + */ + switchStage(stage) { + assert(stage, "Got empty stage"); + if (stage !== this.stage) { + this.stage = stage; + logger.log(this.stage); + return true; + } else { + // log(this, "Re entering", stage); + return false; + } + } + + // GameState implementation + getInnerHTML() { + return ""; + } + + getThemeMusic() { + if (this.creationPayload.gameModeId && this.creationPayload.gameModeId.includes("puzzle")) { + return MUSIC.puzzle; + } + return MUSIC.theme; + } + + onBeforeExit() { + // logger.log("Saving before quitting"); + // return this.doSave().then(() => { + // logger.log(this, "Successfully saved"); + // // this.stageDestroyed(); + // }); + } + + onAppPause() { + // if (this.stage === stages.s10_gameRunning) { + // logger.log("Saving because app got paused"); + // this.doSave(); + // } + } + + getHasFadeIn() { + return false; + } + + getPauseOnFocusLost() { + return false; + } + + getHasUnloadConfirmation() { + return true; + } + + onLeave() { + if (this.core) { + this.stageDestroyed(); + } + this.app.inputMgr.dismountFilter(this.boundInputFilter); + } + + onResized(w, h) { + super.onResized(w, h); + if (this.stage === stages.s10_gameRunning) { + this.core.resize(w, h); + } + } + + // ---- End of GameState implementation + + /** + * Goes back to the menu state + */ + goBackToMenu() { + if ([enumGameModeIds.puzzleEdit, enumGameModeIds.puzzlePlay].includes(this.gameModeId)) { + this.saveThenGoToState("PuzzleMenuState"); + } else { + this.saveThenGoToState("MainMenuState"); + } + } + + /** + * Goes back to the settings state + */ + goToSettings() { + this.saveThenGoToState("SettingsState", { + backToStateId: this.key, + backToStatePayload: this.creationPayload, + }); + } + + /** + * Goes back to the settings state + */ + goToKeybindings() { + this.saveThenGoToState("KeybindingsState", { + backToStateId: this.key, + backToStatePayload: this.creationPayload, + }); + } + + /** + * Moves to a state outside of the game + * @param {string} stateId + * @param {any=} payload + */ + saveThenGoToState(stateId, payload) { + if (this.stage === stages.leaving || this.stage === stages.destroyed) { + logger.warn( + "Tried to leave game twice or during destroy:", + this.stage, + "(attempted to move to", + stateId, + ")" + ); + return; + } + this.stageLeavingGame(); + this.doSave().then(() => { + this.stageDestroyed(); + this.moveToState(stateId, payload); + }); + } + + onBackButton() { + // do nothing + } + + /** + * Called when the game somehow failed to initialize. Resets everything to basic state and + * then goes to the main menu, showing the error + * @param {string} err + */ + onInitializationFailure(err) { + if (this.switchStage(stages.initFailed)) { + logger.error("Init failure:", err); + this.stageDestroyed(); + this.moveToState("MainMenuState", { loadError: err }); + } + } + + // STAGES + + /** + * Creates the game core instance, and thus the root + */ + stage3CreateCore() { + if (this.switchStage(stages.s3_createCore)) { + logger.log("Creating new game core"); + this.core = new GameCore(this.app); + + this.core.initializeRoot(this, this.savegame, this.gameModeId); + + if (this.savegame.hasGameDump()) { + this.stage4bResumeGame(); + } else { + this.app.gameAnalytics.handleGameStarted(); + this.stage4aInitEmptyGame(); + } + } + } + + /** + * Initializes a new empty game + */ + stage4aInitEmptyGame() { + if (this.switchStage(stages.s4_A_initEmptyGame)) { + this.core.initNewGame({ + seed: this.seed, + allowNonPrimaryColors: this.allowNonPrimaryColors, + fullShapePercentage: this.fullShapePercentage, + wierdShapePercentage: this.wierdShapePercentage, + }); + this.stage5FirstUpdate(); + } + } + + /** + * Resumes an existing game + */ + stage4bResumeGame() { + if (this.switchStage(stages.s4_B_resumeGame)) { + if (!this.core.initExistingGame()) { + this.onInitializationFailure("Savegame is corrupt and can not be restored."); + return; + } + this.app.gameAnalytics.handleGameResumed(); + this.stage5FirstUpdate(); + } + } + + /** + * Performs the first game update on the game which initializes most caches + */ + stage5FirstUpdate() { + if (this.switchStage(stages.s5_firstUpdate)) { + this.core.root.logicInitialized = true; + this.core.updateLogic(); + this.stage6PostLoadHook(); + } + } + + /** + * Call the post load hook, this means that we have loaded the game, and all systems + * can operate and start to work now. + */ + stage6PostLoadHook() { + if (this.switchStage(stages.s6_postLoadHook)) { + logger.log("Post load hook"); + this.core.postLoadHook(); + this.stage7Warmup(); + } + } + + /** + * This makes the game idle and draw for a while, because we run most code this way + * the V8 engine can already start to optimize it. Also this makes sure the resources + * are in the VRAM and we have a smooth experience once we start. + */ + stage7Warmup() { + if (this.switchStage(stages.s7_warmup)) { + if (this.creationPayload.fastEnter) { + this.warmupTimeSeconds = globalConfig.warmupTimeSecondsFast; + } else { + this.warmupTimeSeconds = globalConfig.warmupTimeSecondsRegular; + } + } + } + + /** + * The final stage where this game is running and updating regulary. + */ + stage10GameRunning() { + if (this.switchStage(stages.s10_gameRunning)) { + this.core.root.signals.readyToRender.dispatch(); + + logSection("GAME STARTED", "#26a69a"); + + // Initial resize, might have changed during loading (this is possible) + this.core.resize(this.app.screenWidth, this.app.screenHeight); + } + } + + /** + * This stage destroys the whole game, used to cleanup + */ + stageDestroyed() { + if (this.switchStage(stages.destroyed)) { + // Cleanup all api calls + this.cancelAllAsyncOperations(); + + if (this.syncer) { + this.syncer.cancelSync(); + this.syncer = null; + } + + // Cleanup core + if (this.core) { + this.core.destruct(); + this.core = null; + } + } + } + + /** + * When leaving the game + */ + stageLeavingGame() { + if (this.switchStage(stages.leaving)) { + // ... + } + } + + // END STAGES + + /** + * Filters the input (keybindings) + */ + filterInput() { + return this.stage === stages.s10_gameRunning; + } + + /** + * @param {GameCreationPayload} payload + */ + onEnter(payload) { + this.app.inputMgr.installFilter(this.boundInputFilter); + + this.creationPayload = payload; + this.savegame = payload.savegame; + this.gameModeId = payload.gameModeId; + this.seed = payload.seed; + this.allowNonPrimaryColors = payload.allowNonPrimaryColors; + this.fullShapePercentage = payload.fullShapePercentage; + this.wierdShapePercentage = payload.wierdShapePercentage; + + this.loadingOverlay = new GameLoadingOverlay(this.app, this.getDivElement()); + this.loadingOverlay.showBasic(); + + // Remove unneded default element + document.body.querySelector(".modalDialogParent").remove(); + + this.asyncChannel + .watch(waitNextFrame()) + .then(() => this.stage3CreateCore()) + .catch(ex => { + logger.error(ex); + throw ex; + }); + } + + /** + * Render callback + * @param {number} dt + */ + onRender(dt) { + if (APPLICATION_ERROR_OCCURED) { + // Application somehow crashed, do not do anything + return; + } + + if (this.stage === stages.s7_warmup) { + this.core.draw(); + this.warmupTimeSeconds -= dt / 1000.0; + if (this.warmupTimeSeconds < 0) { + logger.log("Warmup completed"); + this.stage10GameRunning(); + } + } + + if (this.stage === stages.s10_gameRunning) { + this.core.tick(dt); + } + + // If the stage is still active (This might not be the case if tick() moved us to game over) + if (this.stage === stages.s10_gameRunning) { + // Only draw if page visible + if (this.app.pageVisible) { + this.core.draw(); + } + + this.loadingOverlay.removeIfAttached(); + } else { + if (!this.loadingOverlay.isAttached()) { + this.loadingOverlay.showBasic(); + } + } + } + + onBackgroundTick(dt) { + this.onRender(dt); + } + + /** + * Saves the game + */ + + doSave() { + if (!this.savegame || !this.savegame.isSaveable()) { + return Promise.resolve(); + } + + if (APPLICATION_ERROR_OCCURED) { + logger.warn("skipping save because application crashed"); + return Promise.resolve(); + } + + if ( + this.stage !== stages.s10_gameRunning && + this.stage !== stages.s7_warmup && + this.stage !== stages.leaving + ) { + logger.warn("Skipping save because game is not ready"); + return Promise.resolve(); + } + + if (this.currentSavePromise) { + logger.warn("Skipping double save and returning same promise"); + return this.currentSavePromise; + } + + if (!this.core.root.gameMode.getIsSaveable()) { + return Promise.resolve(); + } + + logger.log("Starting to save game ..."); + this.savegame.updateData(this.core.root); + + this.currentSavePromise = this.savegame + .writeSavegameAndMetadata() + .catch(err => { + // Catch errors + logger.warn("Failed to save:", err); + }) + .then(() => { + // Clear promise + logger.log("Saved!"); + this.core.root.signals.gameSaved.dispatch(); + this.currentSavePromise = null; + }); + + return this.currentSavePromise; + } +} diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 60495a9c..6380ef32 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -3,7 +3,7 @@ import { cachebust } from "../core/cachebust"; import { A_B_TESTING_LINK_TYPE, globalConfig, THIRDPARTY_URLS } from "../core/config"; import { GameState } from "../core/game_state"; import { DialogWithForm } from "../core/modal_dialog_elements"; -import { FormElementInput } from "../core/modal_dialog_forms"; +import { FormElementCheckbox, FormElementDetails, FormElementInput } from "../core/modal_dialog_forms"; import { ReadWriteProxy } from "../core/read_write_proxy"; import { formatSecondsToTimeAgo, @@ -12,6 +12,7 @@ import { makeButton, makeButtonElement, makeDiv, + randomInt, removeAllChildren, startFileChoose, waitNextFrame, @@ -672,14 +673,96 @@ export class MainMenuState extends GameState { return; } - this.app.analytics.trackUiClick("startgame"); - this.app.adProvider.showVideoAd().then(() => { - const savegame = this.app.savegameMgr.createNewSavegame(); + const regex = /^[a-zA-Z0-9_\- ]{1,20}$/; - this.moveToState("InGameState", { - savegame, + const nameInput = new FormElementInput({ + id: "nameInput", + // @TODO: Add translation (T.dialogs.newSavegame.nameInputLabel) + label: "Name:", + placeholder: "", + defaultValue: "Unnamed", + validator: val => val.match(regex) && trim(val).length > 0, + inline: true, + }); + + const seedInput = new FormElementInput({ + id: "seedInput", + // @TODO: Add translation (T.dialogs.newSavegame.seedInputLabel) + label: "Seed:", + placeholder: "", + defaultValue: randomInt(0, 100000).toString(), + validator: val => Number.isInteger(Number(val)) && Number(val) >= 0 && Number(val) <= 100000, + inline: true, + }); + + const allowColorsCheckbox = new FormElementCheckbox({ + id: "allowColorsCheckbox", + // @TODO: Add translation (T.dialogs.newSavegame.allowColorsCheckboxLabel) + label: "Allow non-primarycolors: ", + defaultValue: false, + inline: true, + }); + + const fullShapePercentageInput = new FormElementInput({ + id: "fullShapePercentageInput", + label: "fullShape %:", + placeholder: "", + defaultValue: Number(0).toString(), + validator: val => Number.isInteger(Number(val)) && Number(val) >= 0 && Number(val) <= 100, + inline: true, + }); + + const wierdShapePercentageInput = new FormElementInput({ + id: "wierdShapePercentageInput", + label: "wierdShape %:", + placeholder: "", + defaultValue: Number(0).toString(), + validator: val => Number.isInteger(Number(val)) && Number(val) >= 0 && Number(val) <= 100, + inline: true, + }); + + const advancedContainer = new FormElementDetails({ + id: "advancedContainer", + // @TODO Add translation (T.dialogs.newSavegame.advanced) + label: "Advanced Options", + formElements: [ + seedInput, + allowColorsCheckbox, + fullShapePercentageInput, + wierdShapePercentageInput, + ], + }); + + const dialog = new DialogWithForm({ + app: this.app, + // @TODO: Add translation (T.dialogs.newSavegame.title) + title: "New Game Options", + // @TODO: Add translation (T.dialogs.newSavegame.desc) + desc: "Configure your new savegame", + formElements: [nameInput, advancedContainer], + buttons: ["ok:good:enter"], + }); + this.dialogs.internalShowDialog(dialog); + + dialog.buttonSignals.ok.add(() => { + this.app.analytics.trackUiClick("startgame"); + this.app.adProvider.showVideoAd().then(async () => { + const savegame = this.app.savegameMgr.createNewSavegame(); + const savegameMetadata = this.app.savegameMgr.getGameMetaDataByInternalId( + savegame.internalId + ); + savegameMetadata.name = trim(nameInput.getValue()); + await this.app.savegameMgr.writeAsync(); + + this.moveToState("InGameState", { + savegame, + seed: Number(seedInput.getValue()), + allowNonPrimaryColors: allowColorsCheckbox.getValue(), + fullShapePercentage: Number(fullShapePercentageInput.getValue()), + wierdShapePercentage: Number(wierdShapePercentageInput.getValue()), + }); + this.app.analytics.trackUiClick("startgame_adcomplete"); }); - this.app.analytics.trackUiClick("startgame_adcomplete"); }); }