diff --git a/src/js/game/entity_manager.js b/src/js/game/entity_manager.js index 11bc709c..646054c7 100644 --- a/src/js/game/entity_manager.js +++ b/src/js/game/entity_manager.js @@ -1,252 +1,243 @@ -import { arrayDeleteValue, newEmptyMap, fastArrayDeleteValue } from "../core/utils"; -import { Component } from "./component"; -import { GameRoot } from "./root"; -import { Entity } from "./entity"; -import { BasicSerializableObject, types } from "../savegame/serialization"; -import { createLogger } from "../core/logging"; - -const logger = createLogger("entity_manager"); - -// Manages all entities - -// NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order -// This is slower but we need it for the street path generation - -export class EntityManager extends BasicSerializableObject { - constructor(root) { - super(); - - /** @type {GameRoot} */ - this.root = root; - - /** @type {Array} */ - this.entities = []; - - // We store a seperate list with entities to destroy, since we don't destroy - // them instantly - /** @type {Array} */ - this.destroyList = []; - - // Store a map from componentid to entities - This is used by the game system - // for faster processing - /** @type {Object.>} */ - this.componentToEntity = newEmptyMap(); - - // Store the next uid to use - this.nextUid = 10000; - } - - static getId() { - return "EntityManager"; - } - - static getSchema() { - return { - nextUid: types.uint, - }; - } - - getStatsText() { - return this.entities.length + " entities [" + this.destroyList.length + " to kill]"; - } - - // Main update - update() { - this.processDestroyList(); - } - - /** - * Registers a new entity - * @param {Entity} entity - * @param {number=} uid Optional predefined uid - */ - registerEntity(entity, uid = null) { - assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`); - assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`); - - if (G_IS_DEV && 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); - } - - this.entities.push(entity); - - // Register into the componentToEntity map - for (const componentId in entity.components) { - if (entity.components[componentId]) { - if (this.componentToEntity[componentId]) { - this.componentToEntity[componentId].push(entity); - } else { - this.componentToEntity[componentId] = [entity]; - } - } - } - - // Give each entity a unique id - entity.uid = uid ? uid : this.generateUid(); - entity.registered = true; - - this.root.signals.entityAdded.dispatch(entity); - } - - /** - * Sorts all entitiy lists after a resync - */ - sortEntityLists() { - this.entities.sort((a, b) => a.uid - b.uid); - this.destroyList.sort((a, b) => a.uid - b.uid); - - for (const key in this.componentToEntity) { - this.componentToEntity[key].sort((a, b) => a.uid - b.uid); - } - } - - /** - * Generates a new uid - * @returns {number} - */ - generateUid() { - return this.nextUid++; - } - - /** - * Call to attach a new component after the creation of the entity - * @param {Entity} entity - * @param {Component} component - */ - attachDynamicComponent(entity, component) { - entity.addComponent(component, true); - const componentId = /** @type {typeof Component} */ (component.constructor).getId(); - if (this.componentToEntity[componentId]) { - this.componentToEntity[componentId].push(entity); - } else { - this.componentToEntity[componentId] = [entity]; - } - this.root.signals.entityGotNewComponent.dispatch(entity); - } - - /** - * Call to remove a component after the creation of the entity - * @param {Entity} entity - * @param {typeof Component} component - */ - removeDynamicComponent(entity, component) { - entity.removeComponent(component, true); - const componentId = /** @type {typeof Component} */ (component.constructor).getId(); - - fastArrayDeleteValue(this.componentToEntity[componentId], entity); - this.root.signals.entityComponentRemoved.dispatch(entity); - } - - /** - * Finds an entity buy its uid, kinda slow since it loops over all entities - * @param {number} uid - * @param {boolean=} errorWhenNotFound - * @returns {Entity} - */ - findByUid(uid, errorWhenNotFound = true) { - const arr = this.entities; - for (let i = 0, len = arr.length; i < len; ++i) { - const entity = arr[i]; - if (entity.uid === uid) { - if (entity.queuedForDestroy || entity.destroyed) { - if (errorWhenNotFound) { - logger.warn("Entity with UID", uid, "not found (destroyed)"); - } - return null; - } - return entity; - } - } - if (errorWhenNotFound) { - logger.warn("Entity with UID", uid, "not found"); - } - return null; - } - - /** - * Returns all entities having the given component - * @param {typeof Component} componentHandle - * @returns {Array} entities - */ - getAllWithComponent(componentHandle) { - return this.componentToEntity[componentHandle.getId()] || []; - } - - /** - * Return all of a given class. This is SLOW! - * @param {object} entityClass - * @returns {Array} entities - */ - getAllOfClass(entityClass) { - // FIXME: Slow - const result = []; - for (let i = 0; i < this.entities.length; ++i) { - const entity = this.entities[i]; - if (entity instanceof entityClass) { - result.push(entity); - } - } - return result; - } - - /** - * Unregisters all components of an entity from the component to entity mapping - * @param {Entity} entity - */ - unregisterEntityComponents(entity) { - for (const componentId in entity.components) { - if (entity.components[componentId]) { - arrayDeleteValue(this.componentToEntity[componentId], entity); - } - } - } - - // Processes the entities to destroy and actually destroys them - /* eslint-disable max-statements */ - processDestroyList() { - for (let i = 0; i < this.destroyList.length; ++i) { - const entity = this.destroyList[i]; - - // Remove from entities list - arrayDeleteValue(this.entities, entity); - - // Remove from componentToEntity list - this.unregisterEntityComponents(entity); - - entity.registered = false; - entity.internalDestroyCallback(); - - this.root.signals.entityDestroyed.dispatch(entity); - } - - this.destroyList = []; - } - - /** - * Queues an entity for destruction - * @param {Entity} entity - */ - destroyEntity(entity) { - if (entity.destroyed) { - logger.error("Tried to destroy already destroyed entity:", entity.uid); - return; - } - - if (entity.queuedForDestroy) { - logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid); - return; - } - - if (this.destroyList.indexOf(entity) < 0) { - this.destroyList.push(entity); - entity.queuedForDestroy = true; - this.root.signals.entityQueuedForDestroy.dispatch(entity); - } else { - assert(false, "Trying to destroy entity twice"); - } - } -} +import { arrayDeleteValue, newEmptyMap, fastArrayDeleteValue } from "../core/utils"; +import { Component } from "./component"; +import { GameRoot } from "./root"; +import { Entity } from "./entity"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { createLogger } from "../core/logging"; +import { globalConfig } from "../core/config"; + +const logger = createLogger("entity_manager"); + +// Manages all entities + +// NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order +// This is slower but we need it for the street path generation + +export class EntityManager extends BasicSerializableObject { + constructor(root) { + super(); + + /** @type {GameRoot} */ + this.root = root; + + /** @type {Array} */ + this.entities = []; + + // We store a seperate list with entities to destroy, since we don't destroy + // them instantly + /** @type {Array} */ + this.destroyList = []; + + // Store a map from componentid to entities - This is used by the game system + // for faster processing + /** @type {Object.>} */ + this.componentToEntity = newEmptyMap(); + + // Store the next uid to use + this.nextUid = 10000; + } + + static getId() { + return "EntityManager"; + } + + static getSchema() { + return { + nextUid: types.uint, + }; + } + + getStatsText() { + return this.entities.length + " entities [" + this.destroyList.length + " to kill]"; + } + + // Main update + update() { + this.processDestroyList(); + } + + /** + * Registers a new entity + * @param {Entity} entity + * @param {number=} uid Optional predefined uid + */ + registerEntity(entity, uid = null) { + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`); + } + assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`); + + if (G_IS_DEV && 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); + } + + this.entities.push(entity); + + // Register into the componentToEntity map + for (const componentId in entity.components) { + if (entity.components[componentId]) { + if (this.componentToEntity[componentId]) { + this.componentToEntity[componentId].push(entity); + } else { + this.componentToEntity[componentId] = [entity]; + } + } + } + + // Give each entity a unique id + entity.uid = uid ? uid : this.generateUid(); + entity.registered = true; + + this.root.signals.entityAdded.dispatch(entity); + } + + /** + * Generates a new uid + * @returns {number} + */ + generateUid() { + return this.nextUid++; + } + + /** + * Call to attach a new component after the creation of the entity + * @param {Entity} entity + * @param {Component} component + */ + attachDynamicComponent(entity, component) { + entity.addComponent(component, true); + const componentId = /** @type {typeof Component} */ (component.constructor).getId(); + if (this.componentToEntity[componentId]) { + this.componentToEntity[componentId].push(entity); + } else { + this.componentToEntity[componentId] = [entity]; + } + this.root.signals.entityGotNewComponent.dispatch(entity); + } + + /** + * Call to remove a component after the creation of the entity + * @param {Entity} entity + * @param {typeof Component} component + */ + removeDynamicComponent(entity, component) { + entity.removeComponent(component, true); + const componentId = /** @type {typeof Component} */ (component.constructor).getId(); + + fastArrayDeleteValue(this.componentToEntity[componentId], entity); + this.root.signals.entityComponentRemoved.dispatch(entity); + } + + /** + * Finds an entity buy its uid, kinda slow since it loops over all entities + * @param {number} uid + * @param {boolean=} errorWhenNotFound + * @returns {Entity} + */ + findByUid(uid, errorWhenNotFound = true) { + const arr = this.entities; + for (let i = 0, len = arr.length; i < len; ++i) { + const entity = arr[i]; + if (entity.uid === uid) { + if (entity.queuedForDestroy || entity.destroyed) { + if (errorWhenNotFound) { + logger.warn("Entity with UID", uid, "not found (destroyed)"); + } + return null; + } + return entity; + } + } + if (errorWhenNotFound) { + logger.warn("Entity with UID", uid, "not found"); + } + return null; + } + + /** + * Returns all entities having the given component + * @param {typeof Component} componentHandle + * @returns {Array} entities + */ + getAllWithComponent(componentHandle) { + return this.componentToEntity[componentHandle.getId()] || []; + } + + /** + * Return all of a given class. This is SLOW! + * @param {object} entityClass + * @returns {Array} entities + */ + getAllOfClass(entityClass) { + // FIXME: Slow + const result = []; + for (let i = 0; i < this.entities.length; ++i) { + const entity = this.entities[i]; + if (entity instanceof entityClass) { + result.push(entity); + } + } + return result; + } + + /** + * Unregisters all components of an entity from the component to entity mapping + * @param {Entity} entity + */ + unregisterEntityComponents(entity) { + for (const componentId in entity.components) { + if (entity.components[componentId]) { + arrayDeleteValue(this.componentToEntity[componentId], entity); + } + } + } + + // Processes the entities to destroy and actually destroys them + /* eslint-disable max-statements */ + processDestroyList() { + for (let i = 0; i < this.destroyList.length; ++i) { + const entity = this.destroyList[i]; + + // Remove from entities list + arrayDeleteValue(this.entities, entity); + + // Remove from componentToEntity list + this.unregisterEntityComponents(entity); + + entity.registered = false; + entity.internalDestroyCallback(); + + this.root.signals.entityDestroyed.dispatch(entity); + } + + this.destroyList = []; + } + + /** + * Queues an entity for destruction + * @param {Entity} entity + */ + destroyEntity(entity) { + if (entity.destroyed) { + logger.error("Tried to destroy already destroyed entity:", entity.uid); + return; + } + + if (entity.queuedForDestroy) { + logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid); + return; + } + + if (this.destroyList.indexOf(entity) < 0) { + this.destroyList.push(entity); + entity.queuedForDestroy = true; + this.root.signals.entityQueuedForDestroy.dispatch(entity); + } else { + assert(false, "Trying to destroy entity twice"); + } + } +} diff --git a/src/js/game/game_system_with_filter.js b/src/js/game/game_system_with_filter.js index 7b1ffbf0..988f09c4 100644 --- a/src/js/game/game_system_with_filter.js +++ b/src/js/game/game_system_with_filter.js @@ -1,131 +1,136 @@ -/* typehints:start */ -import { Component } from "./component"; -import { Entity } from "./entity"; -/* typehints:end */ - -import { GameRoot } from "./root"; -import { GameSystem } from "./game_system"; -import { arrayDelete, arrayDeleteValue } from "../core/utils"; - -export class GameSystemWithFilter extends GameSystem { - /** - * Constructs a new game system with the given component filter. It will process - * all entities which have *all* of the passed components - * @param {GameRoot} root - * @param {Array} requiredComponents - */ - constructor(root, requiredComponents) { - super(root); - this.requiredComponents = requiredComponents; - this.requiredComponentIds = requiredComponents.map(component => component.getId()); - - /** - * All entities which match the current components - * @type {Array} - */ - this.allEntities = []; - - this.root.signals.entityAdded.add(this.internalPushEntityIfMatching, this); - this.root.signals.entityGotNewComponent.add(this.internalReconsiderEntityToAdd, this); - this.root.signals.entityComponentRemoved.add(this.internalCheckEntityAfterComponentRemoval, this); - this.root.signals.entityQueuedForDestroy.add(this.internalPopEntityIfMatching, this); - - this.root.signals.postLoadHook.add(this.internalPostLoadHook, this); - this.root.signals.bulkOperationFinished.add(this.refreshCaches, this); - } - - /** - * @param {Entity} entity - */ - internalPushEntityIfMatching(entity) { - for (let i = 0; i < this.requiredComponentIds.length; ++i) { - if (!entity.components[this.requiredComponentIds[i]]) { - return; - } - } - - assert(this.allEntities.indexOf(entity) < 0, "entity already in list: " + entity); - this.internalRegisterEntity(entity); - } - - /** - * - * @param {Entity} entity - */ - internalCheckEntityAfterComponentRemoval(entity) { - if (this.allEntities.indexOf(entity) < 0) { - // Entity wasn't interesting anyways - return; - } - - for (let i = 0; i < this.requiredComponentIds.length; ++i) { - if (!entity.components[this.requiredComponentIds[i]]) { - // Entity is not interesting anymore - arrayDeleteValue(this.allEntities, entity); - } - } - } - - /** - * - * @param {Entity} entity - */ - internalReconsiderEntityToAdd(entity) { - for (let i = 0; i < this.requiredComponentIds.length; ++i) { - if (!entity.components[this.requiredComponentIds[i]]) { - return; - } - } - if (this.allEntities.indexOf(entity) >= 0) { - return; - } - this.internalRegisterEntity(entity); - } - - refreshCaches() { - this.allEntities.sort((a, b) => a.uid - b.uid); - - // Remove all entities which are queued for destroy - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - if (entity.queuedForDestroy || entity.destroyed) { - this.allEntities.splice(i, 1); - } - } - } - - /** - * Recomputes all target entities after the game has loaded - */ - internalPostLoadHook() { - this.refreshCaches(); - } - - /** - * - * @param {Entity} entity - */ - internalRegisterEntity(entity) { - this.allEntities.push(entity); - - if (this.root.gameInitialized && !this.root.bulkOperationRunning) { - // Sort entities by uid so behaviour is predictable - this.allEntities.sort((a, b) => a.uid - b.uid); - } - } - - /** - * - * @param {Entity} entity - */ - internalPopEntityIfMatching(entity) { - if (this.root.bulkOperationRunning) { - // We do this in refreshCaches afterwards - return; - } - const index = this.allEntities.indexOf(entity); - if (index >= 0) { - arrayDelete(this.allEntities, index); - } - } -} +/* typehints:start */ +import { Component } from "./component"; +import { Entity } from "./entity"; +/* typehints:end */ + +import { GameRoot } from "./root"; +import { GameSystem } from "./game_system"; +import { arrayDelete, arrayDeleteValue } from "../core/utils"; +import { globalConfig } from "../core/config"; + +export class GameSystemWithFilter extends GameSystem { + /** + * Constructs a new game system with the given component filter. It will process + * all entities which have *all* of the passed components + * @param {GameRoot} root + * @param {Array} requiredComponents + */ + constructor(root, requiredComponents) { + super(root); + this.requiredComponents = requiredComponents; + this.requiredComponentIds = requiredComponents.map(component => component.getId()); + + /** + * All entities which match the current components + * @type {Array} + */ + this.allEntities = []; + + this.root.signals.entityAdded.add(this.internalPushEntityIfMatching, this); + this.root.signals.entityGotNewComponent.add(this.internalReconsiderEntityToAdd, this); + this.root.signals.entityComponentRemoved.add(this.internalCheckEntityAfterComponentRemoval, this); + this.root.signals.entityQueuedForDestroy.add(this.internalPopEntityIfMatching, this); + + this.root.signals.postLoadHook.add(this.internalPostLoadHook, this); + this.root.signals.bulkOperationFinished.add(this.refreshCaches, this); + } + + /** + * @param {Entity} entity + */ + internalPushEntityIfMatching(entity) { + for (let i = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + + // This is slow! + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + assert(this.allEntities.indexOf(entity) < 0, "entity already in list: " + entity); + } + + this.internalRegisterEntity(entity); + } + + /** + * + * @param {Entity} entity + */ + internalCheckEntityAfterComponentRemoval(entity) { + if (this.allEntities.indexOf(entity) < 0) { + // Entity wasn't interesting anyways + return; + } + + for (let i = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + // Entity is not interesting anymore + arrayDeleteValue(this.allEntities, entity); + } + } + } + + /** + * + * @param {Entity} entity + */ + internalReconsiderEntityToAdd(entity) { + for (let i = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + if (this.allEntities.indexOf(entity) >= 0) { + return; + } + this.internalRegisterEntity(entity); + } + + refreshCaches() { + this.allEntities.sort((a, b) => a.uid - b.uid); + + // Remove all entities which are queued for destroy + for (let i = 0; i < this.allEntities.length; ++i) { + const entity = this.allEntities[i]; + if (entity.queuedForDestroy || entity.destroyed) { + this.allEntities.splice(i, 1); + } + } + } + + /** + * Recomputes all target entities after the game has loaded + */ + internalPostLoadHook() { + this.refreshCaches(); + } + + /** + * + * @param {Entity} entity + */ + internalRegisterEntity(entity) { + this.allEntities.push(entity); + + if (this.root.gameInitialized && !this.root.bulkOperationRunning) { + // Sort entities by uid so behaviour is predictable + this.allEntities.sort((a, b) => a.uid - b.uid); + } + } + + /** + * + * @param {Entity} entity + */ + internalPopEntityIfMatching(entity) { + if (this.root.bulkOperationRunning) { + // We do this in refreshCaches afterwards + return; + } + const index = this.allEntities.indexOf(entity); + if (index >= 0) { + arrayDelete(this.allEntities, index); + } + } +} diff --git a/src/js/game/systems/item_acceptor.js b/src/js/game/systems/item_acceptor.js index 6d6fec77..9a985836 100644 --- a/src/js/game/systems/item_acceptor.js +++ b/src/js/game/systems/item_acceptor.js @@ -9,14 +9,30 @@ import { MapChunkView } from "../map_chunk_view"; export class ItemAcceptorSystem extends GameSystemWithFilter { constructor(root) { super(root, [ItemAcceptorComponent]); + + // Well ... it's better to be verbose I guess? + this.accumulatedTicksWhileInMapOverview = 0; } update() { + // This system doesn't render anything while in map overview, + // so simply accumulate ticks + if (this.root.camera.getIsMapOverlayActive()) { + ++this.accumulatedTicksWhileInMapOverview; + return; + } + + // Compute how much ticks we missed + const numTicks = 1 + this.accumulatedTicksWhileInMapOverview; const progress = this.root.dynamicTickrate.deltaSeconds * 2 * this.root.hubGoals.getBeltBaseSpeed() * - globalConfig.itemSpacingOnBelts; // * 2 because its only a half tile + globalConfig.itemSpacingOnBelts * // * 2 because its only a half tile + numTicks; + + // Reset accumulated ticks + this.accumulatedTicksWhileInMapOverview = 0; for (let i = 0; i < this.allEntities.length; ++i) { const entity = this.allEntities[i]; diff --git a/src/js/game/systems/wired_pins.js b/src/js/game/systems/wired_pins.js index 202691b8..e8bc1882 100644 --- a/src/js/game/systems/wired_pins.js +++ b/src/js/game/systems/wired_pins.js @@ -149,8 +149,6 @@ export class WiredPinsSystem extends GameSystemWithFilter { } } - update() {} - /** * Draws a given entity * @param {DrawParameters} parameters diff --git a/src/js/savegame/savegame_serializer.js b/src/js/savegame/savegame_serializer.js index 92db738b..552bc35c 100644 --- a/src/js/savegame/savegame_serializer.js +++ b/src/js/savegame/savegame_serializer.js @@ -1,146 +1,146 @@ -import { ExplainedResult } from "../core/explained_result"; -import { createLogger } from "../core/logging"; -import { gComponentRegistry } from "../core/global_registries"; -import { SerializerInternal } from "./serializer_internal"; - -/** - * @typedef {import("../game/component").Component} Component - * @typedef {import("../game/component").StaticComponent} StaticComponent - * @typedef {import("../game/entity").Entity} Entity - * @typedef {import("../game/root").GameRoot} GameRoot - * @typedef {import("../savegame/savegame_typedefs").SerializedGame} SerializedGame - */ - -const logger = createLogger("savegame_serializer"); - -/** - * Serializes a savegame - */ -export class SavegameSerializer { - constructor() { - this.internal = new SerializerInternal(); - } - - /** - * Serializes the game root into a dump - * @param {GameRoot} root - * @param {boolean=} sanityChecks Whether to check for validity - * @returns {object} - */ - generateDumpFromGameRoot(root, sanityChecks = true) { - /** @type {SerializedGame} */ - const data = { - camera: root.camera.serialize(), - time: root.time.serialize(), - map: root.map.serialize(), - entityMgr: root.entityMgr.serialize(), - hubGoals: root.hubGoals.serialize(), - pinnedShapes: root.hud.parts.pinnedShapes.serialize(), - waypoints: root.hud.parts.waypoints.serialize(), - entities: this.internal.serializeEntityArray(root.entityMgr.entities), - beltPaths: root.systemMgr.systems.belt.serializePaths(), - }; - - if (!G_IS_RELEASE) { - if (sanityChecks) { - // Sanity check - const sanity = this.verifyLogicalErrors(data); - if (!sanity.result) { - logger.error("Created invalid savegame:", sanity.reason, "savegame:", data); - return null; - } - } - } - return data; - } - - /** - * Verifies if there are logical errors in the savegame - * @param {SerializedGame} savegame - * @returns {ExplainedResult} - */ - verifyLogicalErrors(savegame) { - if (!savegame.entities) { - return ExplainedResult.bad("Savegame has no entities"); - } - - const seenUids = []; - - // Check for duplicate UIDS - for (let i = 0; i < savegame.entities.length; ++i) { - /** @type {Entity} */ - const entity = savegame.entities[i]; - - const uid = entity.uid; - if (!Number.isInteger(uid)) { - return ExplainedResult.bad("Entity has invalid uid: " + uid); - } - if (seenUids.indexOf(uid) >= 0) { - return ExplainedResult.bad("Duplicate uid " + uid); - } - seenUids.push(uid); - - // Verify components - if (!entity.components) { - return ExplainedResult.bad("Entity is missing key 'components': " + JSON.stringify(entity)); - } - - const components = entity.components; - for (const componentId in components) { - const componentClass = gComponentRegistry.findById(componentId); - - // Check component id is known - if (!componentClass) { - return ExplainedResult.bad("Unknown component id: " + componentId); - } - - // Verify component data - const componentData = components[componentId]; - const componentVerifyError = /** @type {StaticComponent} */ (componentClass).verify( - componentData - ); - - // Check component data is ok - if (componentVerifyError) { - return ExplainedResult.bad( - "Component " + componentId + " has invalid data: " + componentVerifyError - ); - } - } - } - - return ExplainedResult.good(); - } - - /** - * Tries to load the savegame from a given dump - * @param {SerializedGame} savegame - * @param {GameRoot} root - * @returns {ExplainedResult} - */ - deserialize(savegame, root) { - // Sanity - const verifyResult = this.verifyLogicalErrors(savegame); - if (!verifyResult.result) { - return ExplainedResult.bad(verifyResult.reason); - } - let errorReason = null; - - errorReason = errorReason || root.entityMgr.deserialize(savegame.entityMgr); - errorReason = errorReason || root.time.deserialize(savegame.time); - errorReason = errorReason || root.camera.deserialize(savegame.camera); - errorReason = errorReason || root.map.deserialize(savegame.map); - errorReason = errorReason || root.hubGoals.deserialize(savegame.hubGoals); - errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes); - errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints); - errorReason = errorReason || this.internal.deserializeEntityArray(root, savegame.entities); - errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths); - - // Check for errors - if (errorReason) { - return ExplainedResult.bad(errorReason); - } - - return ExplainedResult.good(); - } -} +import { ExplainedResult } from "../core/explained_result"; +import { createLogger } from "../core/logging"; +import { gComponentRegistry } from "../core/global_registries"; +import { SerializerInternal } from "./serializer_internal"; + +/** + * @typedef {import("../game/component").Component} Component + * @typedef {import("../game/component").StaticComponent} StaticComponent + * @typedef {import("../game/entity").Entity} Entity + * @typedef {import("../game/root").GameRoot} GameRoot + * @typedef {import("../savegame/savegame_typedefs").SerializedGame} SerializedGame + */ + +const logger = createLogger("savegame_serializer"); + +/** + * Serializes a savegame + */ +export class SavegameSerializer { + constructor() { + this.internal = new SerializerInternal(); + } + + /** + * Serializes the game root into a dump + * @param {GameRoot} root + * @param {boolean=} sanityChecks Whether to check for validity + * @returns {object} + */ + generateDumpFromGameRoot(root, sanityChecks = true) { + /** @type {SerializedGame} */ + const data = { + camera: root.camera.serialize(), + time: root.time.serialize(), + map: root.map.serialize(), + entityMgr: root.entityMgr.serialize(), + hubGoals: root.hubGoals.serialize(), + pinnedShapes: root.hud.parts.pinnedShapes.serialize(), + waypoints: root.hud.parts.waypoints.serialize(), + entities: this.internal.serializeEntityArray(root.entityMgr.entities), + beltPaths: root.systemMgr.systems.belt.serializePaths(), + }; + + if (G_IS_DEV) { + if (sanityChecks) { + // Sanity check + const sanity = this.verifyLogicalErrors(data); + if (!sanity.result) { + logger.error("Created invalid savegame:", sanity.reason, "savegame:", data); + return null; + } + } + } + return data; + } + + /** + * Verifies if there are logical errors in the savegame + * @param {SerializedGame} savegame + * @returns {ExplainedResult} + */ + verifyLogicalErrors(savegame) { + if (!savegame.entities) { + return ExplainedResult.bad("Savegame has no entities"); + } + + const seenUids = new Set(); + + // Check for duplicate UIDS + for (let i = 0; i < savegame.entities.length; ++i) { + /** @type {Entity} */ + const entity = savegame.entities[i]; + + const uid = entity.uid; + if (!Number.isInteger(uid)) { + return ExplainedResult.bad("Entity has invalid uid: " + uid); + } + if (seenUids.has(uid)) { + return ExplainedResult.bad("Duplicate uid " + uid); + } + seenUids.add(uid); + + // Verify components + if (!entity.components) { + return ExplainedResult.bad("Entity is missing key 'components': " + JSON.stringify(entity)); + } + + const components = entity.components; + for (const componentId in components) { + const componentClass = gComponentRegistry.findById(componentId); + + // Check component id is known + if (!componentClass) { + return ExplainedResult.bad("Unknown component id: " + componentId); + } + + // Verify component data + const componentData = components[componentId]; + const componentVerifyError = /** @type {StaticComponent} */ (componentClass).verify( + componentData + ); + + // Check component data is ok + if (componentVerifyError) { + return ExplainedResult.bad( + "Component " + componentId + " has invalid data: " + componentVerifyError + ); + } + } + } + + return ExplainedResult.good(); + } + + /** + * Tries to load the savegame from a given dump + * @param {SerializedGame} savegame + * @param {GameRoot} root + * @returns {ExplainedResult} + */ + deserialize(savegame, root) { + // Sanity + const verifyResult = this.verifyLogicalErrors(savegame); + if (!verifyResult.result) { + return ExplainedResult.bad(verifyResult.reason); + } + let errorReason = null; + + errorReason = errorReason || root.entityMgr.deserialize(savegame.entityMgr); + errorReason = errorReason || root.time.deserialize(savegame.time); + errorReason = errorReason || root.camera.deserialize(savegame.camera); + errorReason = errorReason || root.map.deserialize(savegame.map); + errorReason = errorReason || root.hubGoals.deserialize(savegame.hubGoals); + errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes); + errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints); + errorReason = errorReason || this.internal.deserializeEntityArray(root, savegame.entities); + errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths); + + // Check for errors + if (errorReason) { + return ExplainedResult.bad(errorReason); + } + + return ExplainedResult.good(); + } +}