diff --git a/src/css/ingame_hud/game_menu.scss b/src/css/ingame_hud/game_menu.scss index f893904c..e4810c09 100644 --- a/src/css/ingame_hud/game_menu.scss +++ b/src/css/ingame_hud/game_menu.scss @@ -56,6 +56,17 @@ transform: scale(1.1, 1.1); } } + + &.saving { + @include InlineAnimation(0.4s ease-in-out infinite) { + 50% { + opacity: 0.5; + transform: scale(0.8); + } + } + pointer-events: none; + cursor: default; + } } &.settings { diff --git a/src/js/core/loader.js b/src/js/core/loader.js index d7f544e3..cadbc048 100644 --- a/src/js/core/loader.js +++ b/src/js/core/loader.js @@ -1,230 +1,230 @@ -import { makeOffscreenBuffer } from "./buffer_utils"; -import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites"; -import { cachebust } from "./cachebust"; -import { createLogger } from "./logging"; - -/** - * @typedef {import("../application").Application} Application - * @typedef {import("./atlas_definitions").AtlasDefinition} AtlasDefinition; - */ - -const logger = createLogger("loader"); - -const missingSpriteIds = {}; - -class LoaderImpl { - constructor() { - this.app = null; - - /** @type {Map} */ - this.sprites = new Map(); - - this.rawImages = []; - } - - /** - * @param {Application} app - */ - linkAppAfterBoot(app) { - this.app = app; - this.makeSpriteNotFoundCanvas(); - } - - /** - * Fetches a given sprite from the cache - * @param {string} key - * @returns {BaseSprite} - */ - getSpriteInternal(key) { - const sprite = this.sprites.get(key); - if (!sprite) { - if (!missingSpriteIds[key]) { - // Only show error once - missingSpriteIds[key] = true; - logger.error("Sprite '" + key + "' not found!"); - } - return this.spriteNotFoundSprite; - } - return sprite; - } - - /** - * Returns an atlas sprite from the cache - * @param {string} key - * @returns {AtlasSprite} - */ - getSprite(key) { - const sprite = this.getSpriteInternal(key); - assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite"); - return /** @type {AtlasSprite} */ (sprite); - } - - /** - * Returns a regular sprite from the cache - * @param {string} key - * @returns {RegularSprite} - */ - getRegularSprite(key) { - const sprite = this.getSpriteInternal(key); - assert( - sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite, - "Not a regular sprite" - ); - return /** @type {RegularSprite} */ (sprite); - } - - /** - * - * @param {string} key - * @returns {Promise} - */ - internalPreloadImage(key) { - const url = cachebust("res/" + key); - const image = new Image(); - - let triesSoFar = 0; - - return Promise.race([ - new Promise((resolve, reject) => { - setTimeout(reject, G_IS_DEV ? 500 : 10000); - }), - - new Promise(resolve => { - image.onload = () => { - image.onerror = null; - image.onload = null; - - if (typeof image.decode === "function") { - // SAFARI: Image.decode() fails on safari with svgs -> we dont want to fail - // on that - // FIREFOX: Decode never returns if the image is in cache, so call it in background - image.decode().then( - () => null, - () => null - ); - } - resolve(image); - }; - - image.onerror = reason => { - logger.warn("Failed to load '" + url + "':", reason); - if (++triesSoFar < 4) { - logger.log("Retrying to load image from", url); - image.src = url + "?try=" + triesSoFar; - } else { - logger.warn("Failed to load", url, "after", triesSoFar, "tries with reason", reason); - image.onerror = null; - image.onload = null; - resolve(null); - } - }; - - image.src = url; - }), - ]); - } - - /** - * Preloads a sprite - * @param {string} key - * @returns {Promise} - */ - preloadCSSSprite(key) { - return this.internalPreloadImage(key).then(image => { - if (key.indexOf("game_misc") >= 0) { - // Allow access to regular sprites - this.sprites.set(key, new RegularSprite(image, image.width, image.height)); - } - this.rawImages.push(image); - }); - } - - /** - * Preloads an atlas - * @param {AtlasDefinition} atlas - * @returns {Promise} - */ - preloadAtlas(atlas) { - return this.internalPreloadImage(atlas.getFullSourcePath()).then(image => { - // @ts-ignore - image.label = atlas.sourceFileName; - return this.internalParseAtlas(atlas, image); - }); - } - - /** - * - * @param {AtlasDefinition} atlas - * @param {HTMLImageElement} loadedImage - */ - internalParseAtlas({ meta: { scale }, sourceData }, loadedImage) { - this.rawImages.push(loadedImage); - - for (const spriteName in sourceData) { - const { frame, sourceSize, spriteSourceSize } = sourceData[spriteName]; - - let sprite = /** @type {AtlasSprite} */ (this.sprites.get(spriteName)); - - if (!sprite) { - sprite = new AtlasSprite(spriteName); - this.sprites.set(spriteName, sprite); - } - - const link = new SpriteAtlasLink({ - packedX: frame.x, - packedY: frame.y, - packedW: frame.w, - packedH: frame.h, - packOffsetX: spriteSourceSize.x, - packOffsetY: spriteSourceSize.y, - atlas: loadedImage, - w: sourceSize.w, - h: sourceSize.h, - }); - sprite.linksByResolution[scale] = link; - } - } - - /** - * Makes the canvas which shows the question mark, shown when a sprite was not found - */ - makeSpriteNotFoundCanvas() { - const dims = 128; - - const [canvas, context] = makeOffscreenBuffer(dims, dims, { - smooth: false, - label: "not-found-sprite", - }); - context.fillStyle = "#f77"; - context.fillRect(0, 0, dims, dims); - - context.textAlign = "center"; - context.textBaseline = "middle"; - context.fillStyle = "#eee"; - context.font = "25px Arial"; - context.fillText("???", dims / 2, dims / 2); - - // TODO: Not sure why this is set here - // @ts-ignore - canvas.src = "not-found"; - - const sprite = new AtlasSprite("not-found"); - ["0.1", "0.25", "0.5", "0.75", "1"].forEach(resolution => { - sprite.linksByResolution[resolution] = new SpriteAtlasLink({ - packedX: 0, - packedY: 0, - w: dims, - h: dims, - packOffsetX: 0, - packOffsetY: 0, - packedW: dims, - packedH: dims, - atlas: canvas, - }); - }); - - this.spriteNotFoundSprite = sprite; - } -} - -export const Loader = new LoaderImpl(); +import { makeOffscreenBuffer } from "./buffer_utils"; +import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites"; +import { cachebust } from "./cachebust"; +import { createLogger } from "./logging"; + +/** + * @typedef {import("../application").Application} Application + * @typedef {import("./atlas_definitions").AtlasDefinition} AtlasDefinition; + */ + +const logger = createLogger("loader"); + +const missingSpriteIds = {}; + +class LoaderImpl { + constructor() { + this.app = null; + + /** @type {Map} */ + this.sprites = new Map(); + + this.rawImages = []; + } + + /** + * @param {Application} app + */ + linkAppAfterBoot(app) { + this.app = app; + this.makeSpriteNotFoundCanvas(); + } + + /** + * Fetches a given sprite from the cache + * @param {string} key + * @returns {BaseSprite} + */ + getSpriteInternal(key) { + const sprite = this.sprites.get(key); + if (!sprite) { + if (!missingSpriteIds[key]) { + // Only show error once + missingSpriteIds[key] = true; + logger.error("Sprite '" + key + "' not found!"); + } + return this.spriteNotFoundSprite; + } + return sprite; + } + + /** + * Returns an atlas sprite from the cache + * @param {string} key + * @returns {AtlasSprite} + */ + getSprite(key) { + const sprite = this.getSpriteInternal(key); + assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite"); + return /** @type {AtlasSprite} */ (sprite); + } + + /** + * Returns a regular sprite from the cache + * @param {string} key + * @returns {RegularSprite} + */ + getRegularSprite(key) { + const sprite = this.getSpriteInternal(key); + assert( + sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite, + "Not a regular sprite" + ); + return /** @type {RegularSprite} */ (sprite); + } + + /** + * + * @param {string} key + * @returns {Promise} + */ + internalPreloadImage(key) { + const url = cachebust("res/" + key); + const image = new Image(); + + let triesSoFar = 0; + + return Promise.race([ + new Promise((resolve, reject) => { + setTimeout(reject, G_IS_DEV ? 500 : 10000); + }), + + new Promise(resolve => { + image.onload = () => { + image.onerror = null; + image.onload = null; + + if (typeof image.decode === "function") { + // SAFARI: Image.decode() fails on safari with svgs -> we dont want to fail + // on that + // FIREFOX: Decode never returns if the image is in cache, so call it in background + image.decode().then( + () => null, + () => null + ); + } + resolve(image); + }; + + image.onerror = reason => { + logger.warn("Failed to load '" + url + "':", reason); + if (++triesSoFar < 4) { + logger.log("Retrying to load image from", url); + image.src = url + "?try=" + triesSoFar; + } else { + logger.warn("Failed to load", url, "after", triesSoFar, "tries with reason", reason); + image.onerror = null; + image.onload = null; + resolve(null); + } + }; + + image.src = url; + }), + ]); + } + + /** + * Preloads a sprite + * @param {string} key + * @returns {Promise} + */ + preloadCSSSprite(key) { + return this.internalPreloadImage(key).then(image => { + if (key.indexOf("game_misc") >= 0) { + // Allow access to regular sprites + this.sprites.set(key, new RegularSprite(image, image.width, image.height)); + } + this.rawImages.push(image); + }); + } + + /** + * Preloads an atlas + * @param {AtlasDefinition} atlas + * @returns {Promise} + */ + preloadAtlas(atlas) { + return this.internalPreloadImage(atlas.getFullSourcePath()).then(image => { + // @ts-ignore + image.label = atlas.sourceFileName; + return this.internalParseAtlas(atlas, image); + }); + } + + /** + * + * @param {AtlasDefinition} atlas + * @param {HTMLImageElement} loadedImage + */ + internalParseAtlas({ meta: { scale }, sourceData }, loadedImage) { + this.rawImages.push(loadedImage); + + for (const spriteName in sourceData) { + const { frame, sourceSize, spriteSourceSize } = sourceData[spriteName]; + + let sprite = /** @type {AtlasSprite} */ (this.sprites.get(spriteName)); + + if (!sprite) { + sprite = new AtlasSprite(spriteName); + this.sprites.set(spriteName, sprite); + } + + const link = new SpriteAtlasLink({ + packedX: frame.x, + packedY: frame.y, + packedW: frame.w, + packedH: frame.h, + packOffsetX: spriteSourceSize.x, + packOffsetY: spriteSourceSize.y, + atlas: loadedImage, + w: sourceSize.w, + h: sourceSize.h, + }); + sprite.linksByResolution[scale] = link; + } + } + + /** + * Makes the canvas which shows the question mark, shown when a sprite was not found + */ + makeSpriteNotFoundCanvas() { + const dims = 128; + + const [canvas, context] = makeOffscreenBuffer(dims, dims, { + smooth: false, + label: "not-found-sprite", + }); + context.fillStyle = "#f77"; + context.fillRect(0, 0, dims, dims); + + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = "#eee"; + context.font = "25px Arial"; + context.fillText("???", dims / 2, dims / 2); + + // TODO: Not sure why this is set here + // @ts-ignore + canvas.src = "not-found"; + + const sprite = new AtlasSprite("not-found"); + ["0.1", "0.25", "0.5", "0.75", "1"].forEach(resolution => { + sprite.linksByResolution[resolution] = new SpriteAtlasLink({ + packedX: 0, + packedY: 0, + w: dims, + h: dims, + packOffsetX: 0, + packOffsetY: 0, + packedW: dims, + packedH: dims, + atlas: canvas, + }); + }); + + this.spriteNotFoundSprite = sprite; + } +} + +export const Loader = new LoaderImpl(); diff --git a/src/js/game/building_codes.js b/src/js/game/building_codes.js index 05c27f57..7e7cfd3f 100644 --- a/src/js/game/building_codes.js +++ b/src/js/game/building_codes.js @@ -1,83 +1,92 @@ -/* typehints:start */ -import { MetaBuilding } from "./meta_building"; -import { AtlasSprite } from "../core/sprites"; -import { Vector } from "../core/vector"; -/* typehints:end */ - -/** - * @typedef {{ - * metaClass: typeof MetaBuilding, - * metaInstance?: MetaBuilding, - * variant?: string, - * rotationVariant?: number, - * tileSize?: Vector, - * sprite?: AtlasSprite, - * blueprintSprite?: AtlasSprite, - * silhouetteColor?: string - * }} BuildingVariantIdentifier - */ - -/** - * Stores a lookup table for all building variants (for better performance) - * @type {Object} - */ -export const gBuildingVariants = { - // Set later -}; - -/** - * Registers a new variant - * @param {number} id - * @param {typeof MetaBuilding} meta - * @param {string} variant - * @param {number} rotationVariant - */ -export function registerBuildingVariant( - id, - meta, - variant = "default" /* FIXME: Circular dependency, actually its defaultBuildingVariant */, - rotationVariant = 0 -) { - assert(!gBuildingVariants[id], "Duplicate id: " + id); - gBuildingVariants[id] = { - metaClass: meta, - variant, - rotationVariant, - // @ts-ignore - tileSize: new meta().getDimensions(variant), - }; -} - -/** - * - * @param {number} code - * @returns {BuildingVariantIdentifier} - */ -export function getBuildingDataFromCode(code) { - assert(gBuildingVariants[code], "Invalid building code: " + code); - return gBuildingVariants[code]; -} - -/** - * Finds the code for a given variant - * @param {MetaBuilding} metaBuilding - * @param {string} variant - * @param {number} rotationVariant - */ -export function getCodeFromBuildingData(metaBuilding, variant, rotationVariant) { - for (const key in gBuildingVariants) { - const data = gBuildingVariants[key]; - if ( - data.metaInstance.getId() === metaBuilding.getId() && - data.variant === variant && - data.rotationVariant === rotationVariant - ) { - return +key; - } - } - assertAlways( - false, - "Building not found by data: " + metaBuilding.getId() + " / " + variant + " / " + rotationVariant - ); - return 0; -} +/* typehints:start */ +import { MetaBuilding } from "./meta_building"; +import { AtlasSprite } from "../core/sprites"; +import { Vector } from "../core/vector"; +/* typehints:end */ + +/** + * @typedef {{ + * metaClass: typeof MetaBuilding, + * metaInstance?: MetaBuilding, + * variant?: string, + * rotationVariant?: number, + * tileSize?: Vector, + * sprite?: AtlasSprite, + * blueprintSprite?: AtlasSprite, + * silhouetteColor?: string + * }} BuildingVariantIdentifier + */ + +/** + * Stores a lookup table for all building variants (for better performance) + * @type {Object} + */ +export const gBuildingVariants = { + // Set later +}; + +/** + * Mapping from 'metaBuildingId/variant/rotationVariant' to building code + * @type {Map} + */ +const variantsCache = new Map(); + +/** + * Registers a new variant + * @param {number} code + * @param {typeof MetaBuilding} meta + * @param {string} variant + * @param {number} rotationVariant + */ +export function registerBuildingVariant( + code, + meta, + variant = "default" /* FIXME: Circular dependency, actually its defaultBuildingVariant */, + rotationVariant = 0 +) { + assert(!gBuildingVariants[code], "Duplicate id: " + code); + gBuildingVariants[code] = { + metaClass: meta, + variant, + rotationVariant, + // @ts-ignore + tileSize: new meta().getDimensions(variant), + }; +} + +/** + * + * @param {number} code + * @returns {BuildingVariantIdentifier} + */ +export function getBuildingDataFromCode(code) { + assert(gBuildingVariants[code], "Invalid building code: " + code); + return gBuildingVariants[code]; +} + +/** + * Builds the cache for the codes + */ +export function buildBuildingCodeCache() { + for (const code in gBuildingVariants) { + const data = gBuildingVariants[code]; + const hash = data.metaInstance.getId() + "/" + data.variant + "/" + data.rotationVariant; + variantsCache.set(hash, +code); + } +} + +/** + * Finds the code for a given variant + * @param {MetaBuilding} metaBuilding + * @param {string} variant + * @param {number} rotationVariant + * @returns {number} + */ +export function getCodeFromBuildingData(metaBuilding, variant, rotationVariant) { + const hash = metaBuilding.getId() + "/" + variant + "/" + rotationVariant; + const result = variantsCache.get(hash); + if (G_IS_DEV) { + assertAlways(!!result, "Building not found by data: " + hash); + } + return result; +} diff --git a/src/js/game/entity_manager.js b/src/js/game/entity_manager.js index 646054c7..c76bd46d 100644 --- a/src/js/game/entity_manager.js +++ b/src/js/game/entity_manager.js @@ -67,11 +67,8 @@ export class EntityManager extends BasicSerializableObject { } assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`); - if (G_IS_DEV && uid !== null) { + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts && uid !== null) { assert(!this.findByUid(uid, false), "Entity uid already taken: " + uid); - } - - if (uid !== null) { assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid); } diff --git a/src/js/game/hud/parts/game_menu.js b/src/js/game/hud/parts/game_menu.js index 59ba0232..6db50066 100644 --- a/src/js/game/hud/parts/game_menu.js +++ b/src/js/game/hud/parts/game_menu.js @@ -5,6 +5,7 @@ import { enumNotificationType } from "./notifications"; import { T } from "../../../translations"; import { KEYMAPPINGS } from "../../key_action_mapper"; import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { TrackedState } from "../../../core/tracked_state"; export class HUDGameMenu extends BaseHUDPart { createElements(parent) { @@ -97,12 +98,17 @@ export class HUDGameMenu extends BaseHUDPart { initialize() { this.root.signals.gameSaved.add(this.onGameSaved, this); + + this.trackedIsSaving = new TrackedState(this.onIsSavingChanged, this); } update() { let playSound = false; let notifications = new Set(); + // Check whether we are saving + this.trackedIsSaving.set(!!this.root.gameState.currentSavePromise); + // Update visibility of buttons for (let i = 0; i < this.visibilityToUpdate.length; ++i) { const { condition, domAttach } = this.visibilityToUpdate[i]; @@ -154,6 +160,10 @@ export class HUDGameMenu extends BaseHUDPart { }); } + onIsSavingChanged(isSaving) { + this.saveButton.classList.toggle("saving", isSaving); + } + onGameSaved() { this.saveButton.classList.toggle("animEven"); this.saveButton.classList.toggle("animOdd"); diff --git a/src/js/game/hud/parts/notifications.js b/src/js/game/hud/parts/notifications.js index aef0cc75..bef8dd0f 100644 --- a/src/js/game/hud/parts/notifications.js +++ b/src/js/game/hud/parts/notifications.js @@ -1,56 +1,55 @@ -import { BaseHUDPart } from "../base_hud_part"; -import { makeDiv } from "../../../core/utils"; -import { T } from "../../../translations"; -import { IS_DEMO } from "../../../core/config"; - -/** @enum {string} */ -export const enumNotificationType = { - saved: "saved", - upgrade: "upgrade", - success: "success", -}; - -const notificationDuration = 3; - -export class HUDNotifications extends BaseHUDPart { - createElements(parent) { - this.element = makeDiv(parent, "ingame_HUD_Notifications", [], ``); - } - - initialize() { - this.root.hud.signals.notification.add(this.onNotification, this); - - /** @type {Array<{ element: HTMLElement, expireAt: number}>} */ - this.notificationElements = []; - - // Automatic notifications - this.root.signals.gameSaved.add(() => - this.onNotification(T.ingame.notifications.gameSaved, enumNotificationType.saved) - ); - } - - /** - * @param {string} message - * @param {enumNotificationType} type - */ - onNotification(message, type) { - const element = makeDiv(this.element, null, ["notification", "type-" + type], message); - element.setAttribute("data-icon", "icons/notification_" + type + ".png"); - - this.notificationElements.push({ - element, - expireAt: this.root.time.realtimeNow() + notificationDuration, - }); - } - - update() { - const now = this.root.time.realtimeNow(); - for (let i = 0; i < this.notificationElements.length; ++i) { - const handle = this.notificationElements[i]; - if (handle.expireAt <= now) { - handle.element.remove(); - this.notificationElements.splice(i, 1); - } - } - } -} +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; + +/** @enum {string} */ +export const enumNotificationType = { + saved: "saved", + upgrade: "upgrade", + success: "success", +}; + +const notificationDuration = 3; + +export class HUDNotifications extends BaseHUDPart { + createElements(parent) { + this.element = makeDiv(parent, "ingame_HUD_Notifications", [], ``); + } + + initialize() { + this.root.hud.signals.notification.add(this.onNotification, this); + + /** @type {Array<{ element: HTMLElement, expireAt: number}>} */ + this.notificationElements = []; + + // Automatic notifications + this.root.signals.gameSaved.add(() => + this.onNotification(T.ingame.notifications.gameSaved, enumNotificationType.saved) + ); + } + + /** + * @param {string} message + * @param {enumNotificationType} type + */ + onNotification(message, type) { + const element = makeDiv(this.element, null, ["notification", "type-" + type], message); + element.setAttribute("data-icon", "icons/notification_" + type + ".png"); + + this.notificationElements.push({ + element, + expireAt: this.root.time.realtimeNow() + notificationDuration, + }); + } + + update() { + const now = this.root.time.realtimeNow(); + for (let i = 0; i < this.notificationElements.length; ++i) { + const handle = this.notificationElements[i]; + if (handle.expireAt <= now) { + handle.element.remove(); + this.notificationElements.splice(i, 1); + } + } + } +} diff --git a/src/js/game/map.js b/src/js/game/map.js index 5ff51ce8..a5ec8f21 100644 --- a/src/js/game/map.js +++ b/src/js/game/map.js @@ -1,236 +1,236 @@ -import { globalConfig } from "../core/config"; -import { Vector } from "../core/vector"; -import { BasicSerializableObject, types } from "../savegame/serialization"; -import { BaseItem } from "./base_item"; -import { Entity } from "./entity"; -import { MapChunkView } from "./map_chunk_view"; -import { GameRoot } from "./root"; - -export class BaseMap extends BasicSerializableObject { - static getId() { - return "Map"; - } - - static getSchema() { - return { - seed: types.uint, - }; - } - - /** - * - * @param {GameRoot} root - */ - constructor(root) { - super(); - this.root = root; - - this.seed = 0; - - /** - * Mapping of 'X|Y' to chunk - * @type {Map} */ - this.chunksById = new Map(); - } - - /** - * Returns the given chunk by index - * @param {number} chunkX - * @param {number} chunkY - */ - getChunk(chunkX, chunkY, createIfNotExistent = false) { - const chunkIdentifier = chunkX + "|" + chunkY; - let storedChunk; - - if ((storedChunk = this.chunksById.get(chunkIdentifier))) { - return storedChunk; - } - - if (createIfNotExistent) { - const instance = new MapChunkView(this.root, chunkX, chunkY); - this.chunksById.set(chunkIdentifier, instance); - return instance; - } - - return null; - } - - /** - * Gets or creates a new chunk if not existent for the given tile - * @param {number} tileX - * @param {number} tileY - * @returns {MapChunkView} - */ - getOrCreateChunkAtTile(tileX, tileY) { - const chunkX = Math.floor(tileX / globalConfig.mapChunkSize); - const chunkY = Math.floor(tileY / globalConfig.mapChunkSize); - return this.getChunk(chunkX, chunkY, true); - } - - /** - * Gets a chunk if not existent for the given tile - * @param {number} tileX - * @param {number} tileY - * @returns {MapChunkView?} - */ - getChunkAtTileOrNull(tileX, tileY) { - const chunkX = Math.floor(tileX / globalConfig.mapChunkSize); - const chunkY = Math.floor(tileY / globalConfig.mapChunkSize); - return this.getChunk(chunkX, chunkY, false); - } - - /** - * Checks if a given tile is within the map bounds - * @param {Vector} tile - * @returns {boolean} - */ - isValidTile(tile) { - if (G_IS_DEV) { - assert(tile instanceof Vector, "tile is not a vector"); - } - return Number.isInteger(tile.x) && Number.isInteger(tile.y); - } - - /** - * Returns the tile content of a given tile - * @param {Vector} tile - * @param {Layer} layer - * @returns {Entity} Entity or null - */ - getTileContent(tile, layer) { - if (G_IS_DEV) { - this.internalCheckTile(tile); - } - const chunk = this.getChunkAtTileOrNull(tile.x, tile.y); - return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer); - } - - /** - * Returns the lower layers content of the given tile - * @param {number} x - * @param {number} y - * @returns {BaseItem=} - */ - getLowerLayerContentXY(x, y) { - return this.getOrCreateChunkAtTile(x, y).getLowerLayerFromWorldCoords(x, y); - } - - /** - * Returns the tile content of a given tile - * @param {number} x - * @param {number} y - * @param {Layer} layer - * @returns {Entity} Entity or null - */ - getLayerContentXY(x, y, layer) { - const chunk = this.getChunkAtTileOrNull(x, y); - return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer); - } - - /** - * Returns the tile contents of a given tile - * @param {number} x - * @param {number} y - * @returns {Array} Entity or null - */ - getLayersContentsMultipleXY(x, y) { - const chunk = this.getChunkAtTileOrNull(x, y); - if (!chunk) { - return []; - } - return chunk.getLayersContentsMultipleFromWorldCoords(x, y); - } - - /** - * Checks if the tile is used - * @param {Vector} tile - * @param {Layer} layer - * @returns {boolean} - */ - isTileUsed(tile, layer) { - if (G_IS_DEV) { - this.internalCheckTile(tile); - } - const chunk = this.getChunkAtTileOrNull(tile.x, tile.y); - return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer) != null; - } - - /** - * Checks if the tile is used - * @param {number} x - * @param {number} y - * @param {Layer} layer - * @returns {boolean} - */ - isTileUsedXY(x, y, layer) { - const chunk = this.getChunkAtTileOrNull(x, y); - return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer) != null; - } - - /** - * Sets the tiles content - * @param {Vector} tile - * @param {Entity} entity - */ - setTileContent(tile, entity) { - if (G_IS_DEV) { - this.internalCheckTile(tile); - } - - this.getOrCreateChunkAtTile(tile.x, tile.y).setLayerContentFromWorldCords( - tile.x, - tile.y, - entity, - entity.layer - ); - - const staticComponent = entity.components.StaticMapEntity; - assert(staticComponent, "Can only place static map entities in tiles"); - } - - /** - * Places an entity with the StaticMapEntity component - * @param {Entity} entity - */ - placeStaticEntity(entity) { - assert(entity.components.StaticMapEntity, "Entity is not static"); - const staticComp = entity.components.StaticMapEntity; - const rect = staticComp.getTileSpaceBounds(); - for (let dx = 0; dx < rect.w; ++dx) { - for (let dy = 0; dy < rect.h; ++dy) { - const x = rect.x + dx; - const y = rect.y + dy; - this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, entity, entity.layer); - } - } - } - - /** - * Removes an entity with the StaticMapEntity component - * @param {Entity} entity - */ - removeStaticEntity(entity) { - assert(entity.components.StaticMapEntity, "Entity is not static"); - const staticComp = entity.components.StaticMapEntity; - const rect = staticComp.getTileSpaceBounds(); - for (let dx = 0; dx < rect.w; ++dx) { - for (let dy = 0; dy < rect.h; ++dy) { - const x = rect.x + dx; - const y = rect.y + dy; - this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, null, entity.layer); - } - } - } - - // Internal - - /** - * Checks a given tile for validty - * @param {Vector} tile - */ - internalCheckTile(tile) { - assert(tile instanceof Vector, "tile is not a vector: " + tile); - assert(tile.x % 1 === 0, "Tile X is not a valid integer: " + tile.x); - assert(tile.y % 1 === 0, "Tile Y is not a valid integer: " + tile.y); - } -} +import { globalConfig } from "../core/config"; +import { Vector } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { BaseItem } from "./base_item"; +import { Entity } from "./entity"; +import { MapChunkView } from "./map_chunk_view"; +import { GameRoot } from "./root"; + +export class BaseMap extends BasicSerializableObject { + static getId() { + return "Map"; + } + + static getSchema() { + return { + seed: types.uint, + }; + } + + /** + * + * @param {GameRoot} root + */ + constructor(root) { + super(); + this.root = root; + + this.seed = 0; + + /** + * Mapping of 'X|Y' to chunk + * @type {Map} */ + this.chunksById = new Map(); + } + + /** + * Returns the given chunk by index + * @param {number} chunkX + * @param {number} chunkY + */ + getChunk(chunkX, chunkY, createIfNotExistent = false) { + const chunkIdentifier = chunkX + "|" + chunkY; + let storedChunk; + + if ((storedChunk = this.chunksById.get(chunkIdentifier))) { + return storedChunk; + } + + if (createIfNotExistent) { + const instance = new MapChunkView(this.root, chunkX, chunkY); + this.chunksById.set(chunkIdentifier, instance); + return instance; + } + + return null; + } + + /** + * Gets or creates a new chunk if not existent for the given tile + * @param {number} tileX + * @param {number} tileY + * @returns {MapChunkView} + */ + getOrCreateChunkAtTile(tileX, tileY) { + const chunkX = Math.floor(tileX / globalConfig.mapChunkSize); + const chunkY = Math.floor(tileY / globalConfig.mapChunkSize); + return this.getChunk(chunkX, chunkY, true); + } + + /** + * Gets a chunk if not existent for the given tile + * @param {number} tileX + * @param {number} tileY + * @returns {MapChunkView?} + */ + getChunkAtTileOrNull(tileX, tileY) { + const chunkX = Math.floor(tileX / globalConfig.mapChunkSize); + const chunkY = Math.floor(tileY / globalConfig.mapChunkSize); + return this.getChunk(chunkX, chunkY, false); + } + + /** + * Checks if a given tile is within the map bounds + * @param {Vector} tile + * @returns {boolean} + */ + isValidTile(tile) { + if (G_IS_DEV) { + assert(tile instanceof Vector, "tile is not a vector"); + } + return Number.isInteger(tile.x) && Number.isInteger(tile.y); + } + + /** + * Returns the tile content of a given tile + * @param {Vector} tile + * @param {Layer} layer + * @returns {Entity} Entity or null + */ + getTileContent(tile, layer) { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + const chunk = this.getChunkAtTileOrNull(tile.x, tile.y); + return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer); + } + + /** + * Returns the lower layers content of the given tile + * @param {number} x + * @param {number} y + * @returns {BaseItem=} + */ + getLowerLayerContentXY(x, y) { + return this.getOrCreateChunkAtTile(x, y).getLowerLayerFromWorldCoords(x, y); + } + + /** + * Returns the tile content of a given tile + * @param {number} x + * @param {number} y + * @param {Layer} layer + * @returns {Entity} Entity or null + */ + getLayerContentXY(x, y, layer) { + const chunk = this.getChunkAtTileOrNull(x, y); + return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer); + } + + /** + * Returns the tile contents of a given tile + * @param {number} x + * @param {number} y + * @returns {Array} Entity or null + */ + getLayersContentsMultipleXY(x, y) { + const chunk = this.getChunkAtTileOrNull(x, y); + if (!chunk) { + return []; + } + return chunk.getLayersContentsMultipleFromWorldCoords(x, y); + } + + /** + * Checks if the tile is used + * @param {Vector} tile + * @param {Layer} layer + * @returns {boolean} + */ + isTileUsed(tile, layer) { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + const chunk = this.getChunkAtTileOrNull(tile.x, tile.y); + return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer) != null; + } + + /** + * Checks if the tile is used + * @param {number} x + * @param {number} y + * @param {Layer} layer + * @returns {boolean} + */ + isTileUsedXY(x, y, layer) { + const chunk = this.getChunkAtTileOrNull(x, y); + return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer) != null; + } + + /** + * Sets the tiles content + * @param {Vector} tile + * @param {Entity} entity + */ + setTileContent(tile, entity) { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + + this.getOrCreateChunkAtTile(tile.x, tile.y).setLayerContentFromWorldCords( + tile.x, + tile.y, + entity, + entity.layer + ); + + const staticComponent = entity.components.StaticMapEntity; + assert(staticComponent, "Can only place static map entities in tiles"); + } + + /** + * Places an entity with the StaticMapEntity component + * @param {Entity} entity + */ + placeStaticEntity(entity) { + assert(entity.components.StaticMapEntity, "Entity is not static"); + const staticComp = entity.components.StaticMapEntity; + const rect = staticComp.getTileSpaceBounds(); + for (let dx = 0; dx < rect.w; ++dx) { + for (let dy = 0; dy < rect.h; ++dy) { + const x = rect.x + dx; + const y = rect.y + dy; + this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, entity, entity.layer); + } + } + } + + /** + * Removes an entity with the StaticMapEntity component + * @param {Entity} entity + */ + removeStaticEntity(entity) { + assert(entity.components.StaticMapEntity, "Entity is not static"); + const staticComp = entity.components.StaticMapEntity; + const rect = staticComp.getTileSpaceBounds(); + for (let dx = 0; dx < rect.w; ++dx) { + for (let dy = 0; dy < rect.h; ++dy) { + const x = rect.x + dx; + const y = rect.y + dy; + this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, null, entity.layer); + } + } + } + + // Internal + + /** + * Checks a given tile for validty + * @param {Vector} tile + */ + internalCheckTile(tile) { + assert(tile instanceof Vector, "tile is not a vector: " + tile); + assert(tile.x % 1 === 0, "Tile X is not a valid integer: " + tile.x); + assert(tile.y % 1 === 0, "Tile Y is not a valid integer: " + tile.y); + } +} diff --git a/src/js/game/map_view.js b/src/js/game/map_view.js index 0e0f3d5b..0f2ceb89 100644 --- a/src/js/game/map_view.js +++ b/src/js/game/map_view.js @@ -66,32 +66,34 @@ export class MapView extends BaseMap { * @param {DrawParameters} drawParameters */ drawStaticEntityDebugOverlays(drawParameters) { - const cullRange = drawParameters.visibleRect.toTileCullRectangle(); - const top = cullRange.top(); - const right = cullRange.right(); - const bottom = cullRange.bottom(); - const left = cullRange.left(); + if (G_IS_DEV && (globalConfig.debug.showAcceptorEjectors || globalConfig.debug.showEntityBounds)) { + const cullRange = drawParameters.visibleRect.toTileCullRectangle(); + const top = cullRange.top(); + const right = cullRange.right(); + const bottom = cullRange.bottom(); + const left = cullRange.left(); - const border = 1; + const border = 1; - const minY = top - border; - const maxY = bottom + border; - const minX = left - border; - const maxX = right + border - 1; + const minY = top - border; + const maxY = bottom + border; + const minX = left - border; + const maxX = right + border - 1; - // Render y from top down for proper blending - for (let y = minY; y <= maxY; ++y) { - for (let x = minX; x <= maxX; ++x) { - // const content = this.tiles[x][y]; - const chunk = this.getChunkAtTileOrNull(x, y); - if (!chunk) { - continue; - } - const content = chunk.getTileContentFromWorldCoords(x, y); - if (content) { - let isBorder = x <= left - 1 || x >= right + 1 || y <= top - 1 || y >= bottom + 1; - if (!isBorder) { - content.drawDebugOverlays(drawParameters); + // Render y from top down for proper blending + for (let y = minY; y <= maxY; ++y) { + for (let x = minX; x <= maxX; ++x) { + // const content = this.tiles[x][y]; + const chunk = this.getChunkAtTileOrNull(x, y); + if (!chunk) { + continue; + } + const content = chunk.getTileContentFromWorldCoords(x, y); + if (content) { + let isBorder = x <= left - 1 || x >= right + 1 || y <= top - 1 || y >= bottom + 1; + if (!isBorder) { + content.drawDebugOverlays(drawParameters); + } } } } diff --git a/src/js/game/meta_building_registry.js b/src/js/game/meta_building_registry.js index 26870d2a..274d82cb 100644 --- a/src/js/game/meta_building_registry.js +++ b/src/js/game/meta_building_registry.js @@ -12,7 +12,7 @@ import { MetaStackerBuilding } from "./buildings/stacker"; import { enumTrashVariants, MetaTrashBuilding } from "./buildings/trash"; import { enumUndergroundBeltVariants, MetaUndergroundBeltBuilding } from "./buildings/underground_belt"; import { MetaWireBuilding } from "./buildings/wire"; -import { gBuildingVariants, registerBuildingVariant } from "./building_codes"; +import { buildBuildingCodeCache, gBuildingVariants, registerBuildingVariant } from "./building_codes"; import { defaultBuildingVariant } from "./meta_building"; import { MetaConstantSignalBuilding } from "./buildings/constant_signal"; import { MetaLogicGateBuilding, enumLogicGateVariants } from "./buildings/logic_gate"; @@ -174,4 +174,7 @@ export function initBuildingCodesAfterResourcesLoaded() { ); variant.silhouetteColor = variant.metaInstance.getSilhouetteColor(); } + + // Update caches + buildBuildingCodeCache(); } diff --git a/src/js/game/systems/storage.js b/src/js/game/systems/storage.js index 5a2b57bb..80affac9 100644 --- a/src/js/game/systems/storage.js +++ b/src/js/game/systems/storage.js @@ -1,101 +1,101 @@ -import { GameSystemWithFilter } from "../game_system_with_filter"; -import { StorageComponent } from "../components/storage"; -import { DrawParameters } from "../../core/draw_parameters"; -import { formatBigNumber, lerp } from "../../core/utils"; -import { Loader } from "../../core/loader"; -import { BOOL_TRUE_SINGLETON, BOOL_FALSE_SINGLETON } from "../items/boolean_item"; -import { MapChunkView } from "../map_chunk_view"; - -export class StorageSystem extends GameSystemWithFilter { - constructor(root) { - super(root, [StorageComponent]); - - this.storageOverlaySprite = Loader.getSprite("sprites/misc/storage_overlay.png"); - - /** - * Stores which uids were already drawn to avoid drawing entities twice - * @type {Set} - */ - this.drawnUids = new Set(); - - this.root.signals.gameFrameStarted.add(this.clearDrawnUids, this); - } - - clearDrawnUids() { - this.drawnUids.clear(); - } - - update() { - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - const storageComp = entity.components.Storage; - const pinsComp = entity.components.WiredPins; - - // Eject from storage - if (storageComp.storedItem && storageComp.storedCount > 0) { - const ejectorComp = entity.components.ItemEjector; - - const nextSlot = ejectorComp.getFirstFreeSlot(); - if (nextSlot !== null) { - if (ejectorComp.tryEject(nextSlot, storageComp.storedItem)) { - storageComp.storedCount--; - - if (storageComp.storedCount === 0) { - storageComp.storedItem = null; - } - } - } - } - - let targetAlpha = storageComp.storedCount > 0 ? 1 : 0; - storageComp.overlayOpacity = lerp(storageComp.overlayOpacity, targetAlpha, 0.05); - - pinsComp.slots[0].value = storageComp.storedItem; - pinsComp.slots[1].value = storageComp.getIsFull() ? BOOL_TRUE_SINGLETON : BOOL_FALSE_SINGLETON; - } - } - - /** - * @param {DrawParameters} parameters - * @param {MapChunkView} chunk - */ - drawChunk(parameters, chunk) { - const contents = chunk.containedEntitiesByLayer.regular; - for (let i = 0; i < contents.length; ++i) { - const entity = contents[i]; - const storageComp = entity.components.Storage; - if (!storageComp) { - continue; - } - - const storedItem = storageComp.storedItem; - if (!storedItem) { - continue; - } - - if (this.drawnUids.has(entity.uid)) { - continue; - } - - this.drawnUids.add(entity.uid); - - const staticComp = entity.components.StaticMapEntity; - - const context = parameters.context; - context.globalAlpha = storageComp.overlayOpacity; - const center = staticComp.getTileSpaceBounds().getCenter().toWorldSpace(); - storedItem.drawItemCenteredClipped(center.x, center.y, parameters, 30); - - this.storageOverlaySprite.drawCached(parameters, center.x - 15, center.y + 15, 30, 15); - - if (parameters.visibleRect.containsCircle(center.x, center.y + 25, 20)) { - context.font = "bold 10px GameFont"; - context.textAlign = "center"; - context.fillStyle = "#64666e"; - context.fillText(formatBigNumber(storageComp.storedCount), center.x, center.y + 25.5); - context.textAlign = "left"; - } - context.globalAlpha = 1; - } - } -} +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { StorageComponent } from "../components/storage"; +import { DrawParameters } from "../../core/draw_parameters"; +import { formatBigNumber, lerp } from "../../core/utils"; +import { Loader } from "../../core/loader"; +import { BOOL_TRUE_SINGLETON, BOOL_FALSE_SINGLETON } from "../items/boolean_item"; +import { MapChunkView } from "../map_chunk_view"; + +export class StorageSystem extends GameSystemWithFilter { + constructor(root) { + super(root, [StorageComponent]); + + this.storageOverlaySprite = Loader.getSprite("sprites/misc/storage_overlay.png"); + + /** + * Stores which uids were already drawn to avoid drawing entities twice + * @type {Set} + */ + this.drawnUids = new Set(); + + this.root.signals.gameFrameStarted.add(this.clearDrawnUids, this); + } + + clearDrawnUids() { + this.drawnUids.clear(); + } + + update() { + for (let i = 0; i < this.allEntities.length; ++i) { + const entity = this.allEntities[i]; + const storageComp = entity.components.Storage; + const pinsComp = entity.components.WiredPins; + + // Eject from storage + if (storageComp.storedItem && storageComp.storedCount > 0) { + const ejectorComp = entity.components.ItemEjector; + + const nextSlot = ejectorComp.getFirstFreeSlot(); + if (nextSlot !== null) { + if (ejectorComp.tryEject(nextSlot, storageComp.storedItem)) { + storageComp.storedCount--; + + if (storageComp.storedCount === 0) { + storageComp.storedItem = null; + } + } + } + } + + let targetAlpha = storageComp.storedCount > 0 ? 1 : 0; + storageComp.overlayOpacity = lerp(storageComp.overlayOpacity, targetAlpha, 0.05); + + pinsComp.slots[0].value = storageComp.storedItem; + pinsComp.slots[1].value = storageComp.getIsFull() ? BOOL_TRUE_SINGLETON : BOOL_FALSE_SINGLETON; + } + } + + /** + * @param {DrawParameters} parameters + * @param {MapChunkView} chunk + */ + drawChunk(parameters, chunk) { + const contents = chunk.containedEntitiesByLayer.regular; + for (let i = 0; i < contents.length; ++i) { + const entity = contents[i]; + const storageComp = entity.components.Storage; + if (!storageComp) { + continue; + } + + const storedItem = storageComp.storedItem; + if (!storedItem) { + continue; + } + + if (this.drawnUids.has(entity.uid)) { + continue; + } + + this.drawnUids.add(entity.uid); + + const staticComp = entity.components.StaticMapEntity; + + const context = parameters.context; + context.globalAlpha = storageComp.overlayOpacity; + const center = staticComp.getTileSpaceBounds().getCenter().toWorldSpace(); + storedItem.drawItemCenteredClipped(center.x, center.y, parameters, 30); + + this.storageOverlaySprite.drawCached(parameters, center.x - 15, center.y + 15, 30, 15); + + if (parameters.visibleRect.containsCircle(center.x, center.y + 25, 20)) { + context.font = "bold 10px GameFont"; + context.textAlign = "center"; + context.fillStyle = "#64666e"; + context.fillText(formatBigNumber(storageComp.storedCount), center.x, center.y + 25.5); + context.textAlign = "left"; + } + context.globalAlpha = 1; + } + } +} diff --git a/src/js/states/ingame.js b/src/js/states/ingame.js index 3eea38b7..2dd2db76 100644 --- a/src/js/states/ingame.js +++ b/src/js/states/ingame.js @@ -1,438 +1,458 @@ -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"; - -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 {Savegame} */ - this.savegame; - } -} - -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; - - this.boundInputFilter = this.filterInput.bind(this); - } - - /** - * 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() { - 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() { - 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); - - 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 (G_IS_DEV && globalConfig.debug.noArtificialDelays) { - this.warmupTimeSeconds = 0.05; - } else { - 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.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()); - } - - /** - * 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(); - } - - // First update the game data - logger.log("Starting to save game ..."); - this.core.root.signals.gameSaved.dispatch(); - this.savegame.updateData(this.core.root); - return this.savegame.writeSavegameAndMetadata().catch(err => { - logger.warn("Failed to save:", err); - }); - } -} +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"; + +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 {Savegame} */ + this.savegame; + } +} + +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() { + 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() { + 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); + + 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 (G_IS_DEV && globalConfig.debug.noArtificialDelays) { + this.warmupTimeSeconds = 0.05; + } else { + 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.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()); + } + + /** + * 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; + } + 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; + } +}