diff --git a/src/js/core/config.local.js b/src/js/core/config.local.js index 87aaaa14..b5aa3572 100644 --- a/src/js/core/config.local.js +++ b/src/js/core/config.local.js @@ -9,7 +9,7 @@ export default { // noArtificialDelays: true, // ----------------------------------------------------------------------------------- // Disables writing of savegames, useful for testing the same savegame over and over - // disableSavegameWrite: true, + disableSavegameWrite: true, // ----------------------------------------------------------------------------------- // Shows bounds of all entities // showEntityBounds: true, @@ -33,7 +33,7 @@ export default { // allBuildingsUnlocked: true, // ----------------------------------------------------------------------------------- // Disables cost of blueprints - // blueprintsNoCost: true, + blueprintsNoCost: true, // ----------------------------------------------------------------------------------- // Disables cost of upgrades // upgradesNoCost: true, @@ -75,7 +75,7 @@ export default { // instantMiners: true, // ----------------------------------------------------------------------------------- // When using fastGameEnter, controls whether a new game is started or the last one is resumed - // resumeGameOnFastEnter: true, + resumeGameOnFastEnter: true, // ----------------------------------------------------------------------------------- // Special option used to render the trailer // renderForTrailer: true, diff --git a/src/js/core/read_write_proxy.js b/src/js/core/read_write_proxy.js index 7c96149b..b2ff9051 100644 --- a/src/js/core/read_write_proxy.js +++ b/src/js/core/read_write_proxy.js @@ -169,7 +169,7 @@ export class ReadWriteProxy { // Check for errors during read .catch(err => { if (err === FILE_NOT_FOUND) { - logger.log("File not found, using default data"); + logger.error("File not found, using default data"); // File not found or unreadable, assume default file return Promise.resolve(null); diff --git a/src/js/core/utils.js b/src/js/core/utils.js index 98327378..a08d577e 100644 --- a/src/js/core/utils.js +++ b/src/js/core/utils.js @@ -671,6 +671,44 @@ export function smoothPulse(time) { return Math.sin(time * 4) * 0.5 + 0.5; } +let logIntervals = {}; +const intervalStyle = "color: grey; font-style: inherit"; +const keyStyle = "color: purple; font-style: italic"; +const revertStyle = "color: inherit; font-style: inherit"; + +export function logInterval(key, frames, message, ...args) { + let interval = logIntervals[key] || 0; + if (++interval > frames) { + console.log( + `%clogInterval [%c${key}%c]: \t%c` + message, + intervalStyle, + keyStyle, + intervalStyle, + revertStyle, + ...args + ); + interval = 0; + } + logIntervals[key] = interval; +} + +export function dirInterval(key, frames, object, premessage, ...args) { + let interval = logIntervals[key] || 0; + if (++interval > frames) { + console.log( + `%cdirInterval [%c${key}%c]: \t%c` + (premessage || ""), + intervalStyle, + keyStyle, + intervalStyle, + revertStyle, + ...args + ); + console.dir(object); + interval = 0; + } + logIntervals[key] = interval; +} + /** * Fills in a tag * @param {string} translation diff --git a/src/js/game/belt_path.js b/src/js/game/belt_path.js index eb55d613..50c2f5e9 100644 --- a/src/js/game/belt_path.js +++ b/src/js/game/belt_path.js @@ -565,9 +565,10 @@ export class BeltPath extends BasicSerializableObject { beltComp.assignedPath = null; const entityLength = beltComp.getEffectiveLengthTiles(); - assert(this.entityPath.indexOf(entity) >= 0, "Entity not contained for split"); - assert(this.entityPath.indexOf(entity) !== 0, "Entity is first"); - assert(this.entityPath.indexOf(entity) !== this.entityPath.length - 1, "Entity is last"); + const index = this.entityPath.indexOf(entity); + assert(index >= 0, "Entity not contained for split"); + assert(index !== 0, "Entity is first"); + assert(index !== this.entityPath.length - 1, "Entity is last"); let firstPathEntityCount = 0; let firstPathLength = 0; diff --git a/src/js/game/blueprint.js b/src/js/game/blueprint.js index 63989393..0fa40921 100644 --- a/src/js/game/blueprint.js +++ b/src/js/game/blueprint.js @@ -57,6 +57,37 @@ export class Blueprint { return new Blueprint(newEntities); } + /** + * Creates a new blueprint from the given entity uids + * @param {Array} entities + */ + static fromEntities(entities) { + const newEntities = []; + + let averagePosition = new Vector(); + + // First, create a copy + for (let i = entities.length - 1; i >= 0; --i) { + const entity = entities[i]; + + const clone = entity.clone(); + newEntities.push(clone); + + const pos = entity.components.StaticMapEntity.getTileSpaceBounds().getCenter(); + averagePosition.addInplace(pos); + } + + averagePosition.divideScalarInplace(entities.length); + const blueprintOrigin = averagePosition.subScalars(0.5, 0.5).floor(); + + for (let i = newEntities.length - 1; i >= 0; --i) { + newEntities[i].components.StaticMapEntity.origin.subInplace(blueprintOrigin); + } + + // Now, make sure the origin is 0,0 + return new Blueprint(newEntities); + } + /** * Returns the cost of this blueprint in shapes */ diff --git a/src/js/game/core.js b/src/js/game/core.js index 2df8989f..8b5cddd6 100644 --- a/src/js/game/core.js +++ b/src/js/game/core.js @@ -421,7 +421,7 @@ export class GameCore { this.overlayAlpha = lerp(this.overlayAlpha, desiredOverlayAlpha, 0.25); // On low performance, skip the fade - if (this.root.entityMgr.entities.length > 5000 || this.root.dynamicTickrate.averageFps < 50) { + if (this.root.entityMgr.entities.size > 5000 || this.root.dynamicTickrate.averageFps < 50) { this.overlayAlpha = desiredOverlayAlpha; } diff --git a/src/js/game/entity_manager.js b/src/js/game/entity_manager.js index b4101fc8..4009c2d5 100644 --- a/src/js/game/entity_manager.js +++ b/src/js/game/entity_manager.js @@ -13,6 +13,9 @@ const logger = createLogger("entity_manager"); // 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 +/** @typedef {number} EntityUid */ +/** @typedef {string} ComponentId */ + export class EntityManager extends BasicSerializableObject { constructor(root) { super(); @@ -20,8 +23,14 @@ export class EntityManager extends BasicSerializableObject { /** @type {GameRoot} */ this.root = root; - /** @type {Array} */ - this.entities = []; + /** @type {Set} */ + this.entities = new Set(); + + /** @type {Map} */ + this.entitiesByUid = new Map(); + + /** @type {Map>} */ + this.entitiesByComponent = new Map(); // We store a separate list with entities to destroy, since we don't destroy // them instantly @@ -30,8 +39,8 @@ export class EntityManager extends BasicSerializableObject { // Store a map from componentid to entities - This is used by the game system // for faster processing - /** @type {Object.>} */ - this.componentToEntity = newEmptyMap(); + ///** @type {Object.>} */ + //this.componentToEntity = newEmptyMap(); // Store the next uid to use this.nextUid = 10000; @@ -48,7 +57,7 @@ export class EntityManager extends BasicSerializableObject { } getStatsText() { - return this.entities.length + " entities [" + this.destroyList.length + " to kill]"; + return this.entities.size + " entities [" + this.destroyList.length + " to kill]"; } // Main update @@ -56,6 +65,19 @@ export class EntityManager extends BasicSerializableObject { this.processDestroyList(); } + /** + * @param {Entity} entity + * @param {ComponentId} componentId + */ + addToComponentMap(entity, componentId) { + let set; + if ((set = this.entitiesByComponent.get(componentId))) { + set.add(entity); + } else { + this.entitiesByComponent.set(componentId, new Set([entity])); + } + } + /** * Registers a new entity * @param {Entity} entity @@ -63,7 +85,7 @@ export class EntityManager extends BasicSerializableObject { */ registerEntity(entity, uid = null) { if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { - assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`); + assert(!this.entities.has(entity), `RegisterEntity() called twice for entity ${entity}`); } assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`); @@ -72,21 +94,17 @@ export class EntityManager extends BasicSerializableObject { assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid); } - this.entities.push(entity); + // Give each entity a unique id + entity.uid = uid || this.generateUid(); + + this.entities.add(entity); + this.entitiesByUid.set(uid, 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]; - } - } + this.addToComponentMap(entity, componentId); } - // Give each entity a unique id - entity.uid = uid ? uid : this.generateUid(); entity.registered = true; this.root.signals.entityAdded.dispatch(entity); @@ -108,11 +126,8 @@ export class EntityManager extends BasicSerializableObject { 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.addToComponentMap(entity, componentId); this.root.signals.entityGotNewComponent.dispatch(entity); } @@ -125,7 +140,7 @@ export class EntityManager extends BasicSerializableObject { entity.removeComponent(component, true); const componentId = /** @type {typeof Component} */ (component.constructor).getId(); - fastArrayDeleteValue(this.componentToEntity[componentId], entity); + this.entitiesByComponent.get(componentId).delete(entity); this.root.signals.entityComponentRemoved.dispatch(entity); } @@ -136,18 +151,15 @@ export class EntityManager extends BasicSerializableObject { * @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; + const entity = this.entitiesByUid.get(uid); + if (entity) { + if (entity.queuedForDestroy || entity.destroyed) { + if (errorWhenNotFound) { + logger.warn("Entity with UID", uid, "not found (destroyed)"); } - return entity; + return null; } + return entity; } if (errorWhenNotFound) { logger.warn("Entity with UID", uid, "not found"); @@ -162,15 +174,7 @@ export class EntityManager extends BasicSerializableObject { * @returns {Map} */ getFrozenUidSearchMap() { - const result = new Map(); - const array = this.entities; - for (let i = 0, len = array.length; i < len; ++i) { - const entity = array[i]; - if (!entity.queuedForDestroy && !entity.destroyed) { - result.set(entity.uid, entity); - } - } - return result; + return this.entitiesByUid; } /** @@ -179,7 +183,9 @@ export class EntityManager extends BasicSerializableObject { * @returns {Array} entities */ getAllWithComponent(componentHandle) { - return this.componentToEntity[componentHandle.getId()] || []; + const set = this.entitiesByComponent.get(componentHandle.getId()); + if (!set) return []; + else return [...set.values()]; } /** @@ -188,20 +194,20 @@ export class EntityManager extends BasicSerializableObject { */ unregisterEntityComponents(entity) { for (const componentId in entity.components) { - if (entity.components[componentId]) { - arrayDeleteValue(this.componentToEntity[componentId], entity); - } + const set = this.entitiesByComponent.get(componentId); + if (set) set.delete(entity); } } // Processes the entities to destroy and actually destroys them /* eslint-disable max-statements */ processDestroyList() { - for (let i = 0; i < this.destroyList.length; ++i) { + for (let i = this.destroyList.length - 1; i >= 0; --i) { const entity = this.destroyList[i]; // Remove from entities list - arrayDeleteValue(this.entities, entity); + this.entities.delete(entity); + this.entitiesByUid.delete(entity.uid); // Remove from componentToEntity list this.unregisterEntityComponents(entity); @@ -230,12 +236,8 @@ export class EntityManager extends BasicSerializableObject { 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"); - } + this.destroyList.push(entity); + entity.queuedForDestroy = true; + this.root.signals.entityQueuedForDestroy.dispatch(entity); } } diff --git a/src/js/game/game_system_with_filter.js b/src/js/game/game_system_with_filter.js index a6efeffd..d76fdcf3 100644 --- a/src/js/game/game_system_with_filter.js +++ b/src/js/game/game_system_with_filter.js @@ -1,137 +1,142 @@ -/* 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() { - // 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); - i -= 1; - } - } - - this.allEntities.sort((a, b) => a.uid - b.uid); - } - - /** - * 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, fastArrayDelete } 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 {Set} + */ + this.allEntitiesSet = new Set(); + this.allEntitiesArray = []; + this.allEntitiesArrayIsOutdated = true; + this.entitiesQueuedToDelete = []; + + 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); + } + + tryUpdateEntitiesArray() { + if (this.allEntitiesArrayIsOutdated) { + this.allEntitiesArray = [...this.allEntitiesSet.values()]; + this.allEntitiesArrayIsOutdated = false; + } + } + + /** + * @param {Entity} entity + */ + internalPushEntityIfMatching(entity) { + for (let i = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + + assert(!this.allEntitiesSet.has(entity), "entity already in list: " + entity); + this.internalRegisterEntity(entity); + } + + /** + * + * @param {Entity} entity + */ + internalCheckEntityAfterComponentRemoval(entity) { + if (!this.allEntitiesSet.has(entity)) { + // 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); + this.allEntitiesArrayIsOutdated = this.allEntitiesSet.delete(entity); + } + } + } + + /** + * + * @param {Entity} entity + */ + internalReconsiderEntityToAdd(entity) { + for (let i = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + if (this.allEntitiesSet.has(entity)) { + return; + } + this.internalRegisterEntity(entity); + } + + refreshCaches() { + //this.allEntities.sort((a, b) => a.uid - b.uid); + // Remove all entities which are queued for destroy + if (this.entitiesQueuedToDelete.length > 0) { + for (let i = this.entitiesQueuedToDelete.length - 1; i >= 0; --i) { + this.allEntitiesSet.delete(this.entitiesQueuedToDelete[i]); + } + this.entitiesQueuedToDelete = []; + } + + // called here in case a delete executed mid frame + this.tryUpdateEntitiesArray(); + } + + /** + * Recomputes all target entities after the game has loaded + */ + internalPostLoadHook() { + this.refreshCaches(); + } + + /** + * + * @param {Entity} entity + */ + internalRegisterEntity(entity) { + this.allEntitiesSet.add(entity); + this.allEntitiesArray.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) { + this.entitiesQueuedToDelete.push(entity); + return; + } + this.allEntitiesArrayIsOutdated = this.allEntitiesSet.delete(entity); + } +} diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index 5f1bd226..5725cea5 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -44,6 +44,7 @@ import { HUDWireInfo } from "./parts/wire_info"; import { HUDLeverToggle } from "./parts/lever_toggle"; import { HUDLayerPreview } from "./parts/layer_preview"; import { HUDMinerHighlight } from "./parts/miner_highlight"; +import { Entity } from "../entity"; import { HUDBetaOverlay } from "./parts/beta_overlay"; import { HUDStandaloneAdvantages } from "./parts/standalone_advantages"; import { HUDCatMemes } from "./parts/cat_memes"; diff --git a/src/js/game/hud/parts/blueprint_placer.js b/src/js/game/hud/parts/blueprint_placer.js index e1040c3b..92f5a0cb 100644 --- a/src/js/game/hud/parts/blueprint_placer.js +++ b/src/js/game/hud/parts/blueprint_placer.js @@ -1,203 +1,204 @@ -import { DrawParameters } from "../../../core/draw_parameters"; -import { STOP_PROPAGATION } from "../../../core/signal"; -import { TrackedState } from "../../../core/tracked_state"; -import { makeDiv } from "../../../core/utils"; -import { Vector } from "../../../core/vector"; -import { SOUNDS } from "../../../platform/sound"; -import { T } from "../../../translations"; -import { Blueprint } from "../../blueprint"; -import { enumMouseButton } from "../../camera"; -import { KEYMAPPINGS } from "../../key_action_mapper"; -import { BaseHUDPart } from "../base_hud_part"; -import { DynamicDomAttach } from "../dynamic_dom_attach"; - -export class HUDBlueprintPlacer extends BaseHUDPart { - createElements(parent) { - const blueprintCostShape = this.root.shapeDefinitionMgr.getShapeFromShortKey( - this.root.gameMode.getBlueprintShapeKey() - ); - const blueprintCostShapeCanvas = blueprintCostShape.generateAsCanvas(80); - - this.costDisplayParent = makeDiv(parent, "ingame_HUD_BlueprintPlacer", [], ``); - - makeDiv(this.costDisplayParent, null, ["label"], T.ingame.blueprintPlacer.cost); - const costContainer = makeDiv(this.costDisplayParent, null, ["costContainer"], ""); - this.costDisplayText = makeDiv(costContainer, null, ["costText"], ""); - costContainer.appendChild(blueprintCostShapeCanvas); - } - - initialize() { - this.root.hud.signals.buildingsSelectedForCopy.add(this.createBlueprintFromBuildings, this); - - /** @type {TypedTrackedState} */ - this.currentBlueprint = new TrackedState(this.onBlueprintChanged, this); - /** @type {Blueprint?} */ - this.lastBlueprintUsed = null; - - const keyActionMapper = this.root.keyMapper; - keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.abortPlacement, this); - keyActionMapper.getBinding(KEYMAPPINGS.placement.pipette).add(this.abortPlacement, this); - keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateWhilePlacing).add(this.rotateBlueprint, this); - keyActionMapper.getBinding(KEYMAPPINGS.massSelect.pasteLastBlueprint).add(this.pasteBlueprint, this); - - this.root.camera.downPreHandler.add(this.onMouseDown, this); - this.root.camera.movePreHandler.add(this.onMouseMove, this); - - this.root.hud.signals.selectedPlacementBuildingChanged.add(this.abortPlacement, this); - this.root.signals.editModeChanged.add(this.onEditModeChanged, this); - - this.domAttach = new DynamicDomAttach(this.root, this.costDisplayParent); - this.trackedCanAfford = new TrackedState(this.onCanAffordChanged, this); - } - - abortPlacement() { - if (this.currentBlueprint.get()) { - this.currentBlueprint.set(null); - - return STOP_PROPAGATION; - } - } - - /** - * Called when the layer was changed - * @param {Layer} layer - */ - onEditModeChanged(layer) { - // Check if the layer of the blueprint differs and thus we have to deselect it - const blueprint = this.currentBlueprint.get(); - if (blueprint) { - if (blueprint.layer !== layer) { - this.currentBlueprint.set(null); - } - } - } - - /** - * Called when the blueprint is now affordable or not - * @param {boolean} canAfford - */ - onCanAffordChanged(canAfford) { - this.costDisplayParent.classList.toggle("canAfford", canAfford); - } - - update() { - const currentBlueprint = this.currentBlueprint.get(); - this.domAttach.update(currentBlueprint && currentBlueprint.getCost() > 0); - this.trackedCanAfford.set(currentBlueprint && currentBlueprint.canAfford(this.root)); - } - - /** - * Called when the blueprint was changed - * @param {Blueprint} blueprint - */ - onBlueprintChanged(blueprint) { - if (blueprint) { - this.lastBlueprintUsed = blueprint; - this.costDisplayText.innerText = "" + blueprint.getCost(); - } - } - - /** - * mouse down pre handler - * @param {Vector} pos - * @param {enumMouseButton} button - */ - onMouseDown(pos, button) { - if (button === enumMouseButton.right) { - if (this.currentBlueprint.get()) { - this.abortPlacement(); - return STOP_PROPAGATION; - } - } - - const blueprint = this.currentBlueprint.get(); - if (!blueprint) { - return; - } - - if (!blueprint.canAfford(this.root)) { - this.root.soundProxy.playUiError(); - return; - } - - const worldPos = this.root.camera.screenToWorld(pos); - const tile = worldPos.toTileSpace(); - if (blueprint.tryPlace(this.root, tile)) { - const cost = blueprint.getCost(); - this.root.hubGoals.takeShapeByKey(this.root.gameMode.getBlueprintShapeKey(), cost); - this.root.soundProxy.playUi(SOUNDS.placeBuilding); - } - } - - /** - * Mose move handler - */ - onMouseMove() { - // Prevent movement while blueprint is selected - if (this.currentBlueprint.get()) { - return STOP_PROPAGATION; - } - } - - /** - * Called when an array of bulidings was selected - * @param {Array} uids - */ - createBlueprintFromBuildings(uids) { - if (uids.length === 0) { - return; - } - this.currentBlueprint.set(Blueprint.fromUids(this.root, uids)); - } - - /** - * Attempts to rotate the current blueprint - */ - rotateBlueprint() { - if (this.currentBlueprint.get()) { - if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed) { - this.currentBlueprint.get().rotateCcw(); - } else { - this.currentBlueprint.get().rotateCw(); - } - } - } - - /** - * Attempts to paste the last blueprint - */ - pasteBlueprint() { - if (this.lastBlueprintUsed !== null) { - if (this.lastBlueprintUsed.layer !== this.root.currentLayer) { - // Not compatible - this.root.soundProxy.playUiError(); - return; - } - - this.root.hud.signals.pasteBlueprintRequested.dispatch(); - this.currentBlueprint.set(this.lastBlueprintUsed); - } else { - this.root.soundProxy.playUiError(); - } - } - - /** - * - * @param {DrawParameters} parameters - */ - draw(parameters) { - const blueprint = this.currentBlueprint.get(); - if (!blueprint) { - return; - } - const mousePosition = this.root.app.mousePosition; - if (!mousePosition) { - // Not on screen - return; - } - - const worldPos = this.root.camera.screenToWorld(mousePosition); - const tile = worldPos.toTileSpace(); - blueprint.draw(parameters, tile); - } -} +import { DrawParameters } from "../../../core/draw_parameters"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { TrackedState } from "../../../core/tracked_state"; +import { makeDiv } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { T } from "../../../translations"; +import { enumMouseButton } from "../../camera"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { Blueprint } from "../../blueprint"; +import { SOUNDS } from "../../../platform/sound"; +import { Entity } from "../../entity"; + +export class HUDBlueprintPlacer extends BaseHUDPart { + createElements(parent) { + const blueprintCostShape = this.root.shapeDefinitionMgr.getShapeFromShortKey( + this.root.gameMode.getBlueprintShapeKey() + ); + const blueprintCostShapeCanvas = blueprintCostShape.generateAsCanvas(80); + + this.costDisplayParent = makeDiv(parent, "ingame_HUD_BlueprintPlacer", [], ``); + + makeDiv(this.costDisplayParent, null, ["label"], T.ingame.blueprintPlacer.cost); + const costContainer = makeDiv(this.costDisplayParent, null, ["costContainer"], ""); + this.costDisplayText = makeDiv(costContainer, null, ["costText"], ""); + costContainer.appendChild(blueprintCostShapeCanvas); + } + + initialize() { + this.root.hud.signals.buildingsSelectedForCopy.add(this.createBlueprintFromBuildings, this); + + /** @type {TypedTrackedState} */ + this.currentBlueprint = new TrackedState(this.onBlueprintChanged, this); + /** @type {Blueprint?} */ + this.lastBlueprintUsed = null; + + const keyActionMapper = this.root.keyMapper; + keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.abortPlacement, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.pipette).add(this.abortPlacement, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateWhilePlacing).add(this.rotateBlueprint, this); + keyActionMapper.getBinding(KEYMAPPINGS.massSelect.pasteLastBlueprint).add(this.pasteBlueprint, this); + + this.root.camera.downPreHandler.add(this.onMouseDown, this); + this.root.camera.movePreHandler.add(this.onMouseMove, this); + + this.root.hud.signals.selectedPlacementBuildingChanged.add(this.abortPlacement, this); + this.root.signals.editModeChanged.add(this.onEditModeChanged, this); + + this.domAttach = new DynamicDomAttach(this.root, this.costDisplayParent); + this.trackedCanAfford = new TrackedState(this.onCanAffordChanged, this); + } + + abortPlacement() { + if (this.currentBlueprint.get()) { + this.currentBlueprint.set(null); + + return STOP_PROPAGATION; + } + } + + /** + * Called when the layer was changed + * @param {Layer} layer + */ + onEditModeChanged(layer) { + // Check if the layer of the blueprint differs and thus we have to deselect it + const blueprint = this.currentBlueprint.get(); + if (blueprint) { + if (blueprint.layer !== layer) { + this.currentBlueprint.set(null); + } + } + } + + /** + * Called when the blueprint is now affordable or not + * @param {boolean} canAfford + */ + onCanAffordChanged(canAfford) { + this.costDisplayParent.classList.toggle("canAfford", canAfford); + } + + update() { + const currentBlueprint = this.currentBlueprint.get(); + this.domAttach.update(currentBlueprint && currentBlueprint.getCost() > 0); + this.trackedCanAfford.set(currentBlueprint && currentBlueprint.canAfford(this.root)); + } + + /** + * Called when the blueprint was changed + * @param {Blueprint} blueprint + */ + onBlueprintChanged(blueprint) { + if (blueprint) { + this.lastBlueprintUsed = blueprint; + this.costDisplayText.innerText = "" + blueprint.getCost(); + } + } + + /** + * mouse down pre handler + * @param {Vector} pos + * @param {enumMouseButton} button + */ + onMouseDown(pos, button) { + if (button === enumMouseButton.right) { + if (this.currentBlueprint.get()) { + this.abortPlacement(); + return STOP_PROPAGATION; + } + } + + const blueprint = this.currentBlueprint.get(); + if (!blueprint) { + return; + } + + if (!blueprint.canAfford(this.root)) { + this.root.soundProxy.playUiError(); + return; + } + + const worldPos = this.root.camera.screenToWorld(pos); + const tile = worldPos.toTileSpace(); + if (blueprint.tryPlace(this.root, tile)) { + const cost = blueprint.getCost(); + this.root.hubGoals.takeShapeByKey(this.root.gameMode.getBlueprintShapeKey(), cost); + this.root.soundProxy.playUi(SOUNDS.placeBuilding); + } + } + + /** + * Mose move handler + */ + onMouseMove() { + // Prevent movement while blueprint is selected + if (this.currentBlueprint.get()) { + return STOP_PROPAGATION; + } + } + + /** + * Called when an array of bulidings was selected + * @param {Array} entities + */ + createBlueprintFromBuildings(entities) { + if (entities.length === 0) { + return; + } + this.currentBlueprint.set(Blueprint.fromEntities(entities)); + } + + /** + * Attempts to rotate the current blueprint + */ + rotateBlueprint() { + if (this.currentBlueprint.get()) { + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed) { + this.currentBlueprint.get().rotateCcw(); + } else { + this.currentBlueprint.get().rotateCw(); + } + } + } + + /** + * Attempts to paste the last blueprint + */ + pasteBlueprint() { + if (this.lastBlueprintUsed !== null) { + if (this.lastBlueprintUsed.layer !== this.root.currentLayer) { + // Not compatible + this.root.soundProxy.playUiError(); + return; + } + + this.root.hud.signals.pasteBlueprintRequested.dispatch(); + this.currentBlueprint.set(this.lastBlueprintUsed); + } else { + this.root.soundProxy.playUiError(); + } + } + + /** + * + * @param {DrawParameters} parameters + */ + draw(parameters) { + const blueprint = this.currentBlueprint.get(); + if (!blueprint) { + return; + } + const mousePosition = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + + const worldPos = this.root.camera.screenToWorld(mousePosition); + const tile = worldPos.toTileSpace(); + blueprint.draw(parameters, tile); + } +} diff --git a/src/js/game/hud/parts/mass_selector.js b/src/js/game/hud/parts/mass_selector.js index 08a11769..87ce453c 100644 --- a/src/js/game/hud/parts/mass_selector.js +++ b/src/js/game/hud/parts/mass_selector.js @@ -5,7 +5,7 @@ import { DrawParameters } from "../../../core/draw_parameters"; import { Entity } from "../../entity"; import { Loader } from "../../../core/loader"; import { globalConfig } from "../../../core/config"; -import { makeDiv, formatBigNumber, formatBigNumberFull } from "../../../core/utils"; +import { makeDiv, formatBigNumber, formatBigNumberFull, dirInterval } from "../../../core/utils"; import { DynamicDomAttach } from "../dynamic_dom_attach"; import { createLogger } from "../../../core/logging"; import { enumMouseButton } from "../../camera"; @@ -23,7 +23,12 @@ export class HUDMassSelector extends BaseHUDPart { initialize() { this.currentSelectionStartWorld = null; this.currentSelectionEnd = null; - this.selectedUids = new Set(); + + /** @type {Set} */ + this.selectedEntities = new Set(); + + /** @type {number} */ + this.selectedUids = 42; this.root.signals.entityQueuedForDestroy.add(this.onEntityDestroyed, this); this.root.hud.signals.pasteBlueprintRequested.add(this.clearSelection, this); @@ -43,6 +48,20 @@ export class HUDMassSelector extends BaseHUDPart { this.root.signals.editModeChanged.add(this.clearSelection, this); } + clear() { + this.selectedEntities.clear(); + } + + // getUidArray() { + // if (this.selectedEntities.size <= 0) return []; + // const uids = []; + // const arr = [...this.selectedEntities.values()]; + // for (let i = arr.length - 1; i >= 0; --i) { + // uids.push(arr[i].uid); + // } + // return uids; + // } + /** * Handles the destroy callback and makes sure we clean our list * @param {Entity} entity @@ -51,7 +70,7 @@ export class HUDMassSelector extends BaseHUDPart { if (this.root.bulkOperationRunning) { return; } - this.selectedUids.delete(entity.uid); + this.selectedEntities.delete(entity); } /** @@ -59,8 +78,8 @@ export class HUDMassSelector extends BaseHUDPart { */ onBack() { // Clear entities on escape - if (this.selectedUids.size > 0) { - this.selectedUids = new Set(); + if (this.selectedEntities.size > 0) { + this.clear(); return STOP_PROPAGATION; } } @@ -69,19 +88,19 @@ export class HUDMassSelector extends BaseHUDPart { * Clears the entire selection */ clearSelection() { - this.selectedUids = new Set(); + this.clear(); } confirmDelete() { if ( !this.root.app.settings.getAllSettings().disableCutDeleteWarnings && - this.selectedUids.size > 100 + this.selectedEntities.size > 100 ) { const { ok } = this.root.hud.parts.dialogs.showWarning( T.dialogs.massDeleteConfirm.title, T.dialogs.massDeleteConfirm.desc.replace( "", - "" + formatBigNumberFull(this.selectedUids.size) + "" + formatBigNumberFull(this.selectedEntities.size) ), ["cancel:good:escape", "ok:bad:enter"] ); @@ -92,35 +111,26 @@ export class HUDMassSelector extends BaseHUDPart { } doDelete() { - const entityUids = Array.from(this.selectedUids); - // Build mapping from uid to entity /** * @type {Map} */ - const mapUidToEntity = this.root.entityMgr.getFrozenUidSearchMap(); + //const mapUidToEntity = this.root.entityMgr.getFrozenUidSearchMap(); this.root.logic.performBulkOperation(() => { - for (let i = 0; i < entityUids.length; ++i) { - const uid = entityUids[i]; - const entity = mapUidToEntity.get(uid); - if (!entity) { - logger.error("Entity not found by uid:", uid); - continue; - } - - if (!this.root.logic.tryDeleteBuilding(entity)) { + const arr = [...this.selectedEntities.values()]; + for (let i = arr.length - 1; i >= 0; --i) { + if (!this.root.logic.tryDeleteBuilding(arr[i])) { logger.error("Error in mass delete, could not remove building"); } } }); - // Clear uids later - this.selectedUids = new Set(); + this.clear(); } startCopy() { - if (this.selectedUids.size > 0) { + if (this.selectedEntities.size > 0) { if (!this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_blueprints)) { this.root.hud.parts.dialogs.showInfo( T.dialogs.blueprintsNotUnlocked.title, @@ -128,8 +138,10 @@ export class HUDMassSelector extends BaseHUDPart { ); return; } - this.root.hud.signals.buildingsSelectedForCopy.dispatch(Array.from(this.selectedUids)); - this.selectedUids = new Set(); + const uids = []; + + this.root.hud.signals.buildingsSelectedForCopy.dispatch([...this.selectedEntities.values()]); + this.selectedEntities.clear(); this.root.soundProxy.playUiClick(); } else { this.root.soundProxy.playUiError(); @@ -144,13 +156,13 @@ export class HUDMassSelector extends BaseHUDPart { ); } else if ( !this.root.app.settings.getAllSettings().disableCutDeleteWarnings && - this.selectedUids.size > 100 + this.selectedEntities.size > 100 ) { const { ok } = this.root.hud.parts.dialogs.showWarning( T.dialogs.massCutConfirm.title, T.dialogs.massCutConfirm.desc.replace( "", - "" + formatBigNumberFull(this.selectedUids.size) + "" + formatBigNumberFull(this.selectedEntities.size) ), ["cancel:good:escape", "ok:bad:enter"] ); @@ -161,26 +173,26 @@ export class HUDMassSelector extends BaseHUDPart { } doCut() { - if (this.selectedUids.size > 0) { - const entityUids = Array.from(this.selectedUids); - - const cutAction = () => { + if (this.selectedEntities.size > 0) { + const cutAction = argArray => { + const arr = argArray || [...this.selectedEntities.values()]; // copy code relies on entities still existing, so must copy before deleting. - this.root.hud.signals.buildingsSelectedForCopy.dispatch(entityUids); - for (let i = 0; i < entityUids.length; ++i) { - const uid = entityUids[i]; - const entity = this.root.entityMgr.findByUid(uid); + this.root.hud.signals.buildingsSelectedForCopy.dispatch(arr); + + for (let i = arr.length - 1; i >= 0; --i) { + const entity = arr[i]; if (!this.root.logic.tryDeleteBuilding(entity)) { logger.error("Error in mass cut, could not remove building"); - this.selectedUids.delete(uid); + this.selectedEntities.delete(entity); } } }; - const blueprint = Blueprint.fromUids(this.root, entityUids); + const arr = [...this.selectedEntities.values()]; + const blueprint = Blueprint.fromEntities(arr); if (blueprint.canAfford(this.root)) { - cutAction(); + cutAction(arr); } else { const { cancel, ok } = this.root.hud.parts.dialogs.showWarning( T.dialogs.massCutInsufficientConfirm.title, @@ -212,7 +224,7 @@ export class HUDMassSelector extends BaseHUDPart { if (!this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectSelectMultiple).pressed) { // Start new selection - this.selectedUids = new Set(); + this.clear(); } this.currentSelectionStartWorld = this.root.camera.screenToWorld(pos.copy()); @@ -245,7 +257,7 @@ export class HUDMassSelector extends BaseHUDPart { for (let y = realTileStart.y; y <= realTileEnd.y; ++y) { const contents = this.root.map.getLayerContentXY(x, y, this.root.currentLayer); if (contents && this.root.logic.canDeleteBuilding(contents)) { - this.selectedUids.add(contents.uid); + this.selectedEntities.add(contents); } } } @@ -319,18 +331,22 @@ export class HUDMassSelector extends BaseHUDPart { } parameters.context.fillStyle = THEME.map.selectionOverlay; - this.selectedUids.forEach(uid => { - const entity = this.root.entityMgr.findByUid(uid); - const staticComp = entity.components.StaticMapEntity; - const bounds = staticComp.getTileSpaceBounds(); - parameters.context.beginRoundedRect( - bounds.x * globalConfig.tileSize + boundsBorder, - bounds.y * globalConfig.tileSize + boundsBorder, - bounds.w * globalConfig.tileSize - 2 * boundsBorder, - bounds.h * globalConfig.tileSize - 2 * boundsBorder, - 2 - ); - parameters.context.fill(); - }); + + if (this.selectedEntities.size > 0) { + const arr = [...this.selectedEntities.values()]; + for (let i = arr.length - 1; i >= 0; --i) { + const entity = arr[i]; + const staticComp = entity.components.StaticMapEntity; + const bounds = staticComp.getTileSpaceBounds(); + parameters.context.beginRoundedRect( + bounds.x * globalConfig.tileSize + boundsBorder, + bounds.y * globalConfig.tileSize + boundsBorder, + bounds.w * globalConfig.tileSize - 2 * boundsBorder, + bounds.h * globalConfig.tileSize - 2 * boundsBorder, + 2 + ); + parameters.context.fill(); + } + } } } diff --git a/src/js/game/systems/belt.js b/src/js/game/systems/belt.js index 10543e6c..181a7fe2 100644 --- a/src/js/game/systems/belt.js +++ b/src/js/game/systems/belt.js @@ -422,8 +422,8 @@ export class BeltSystem extends GameSystemWithFilter { const result = []; - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; if (visitedUids.has(entity.uid)) { continue; } diff --git a/src/js/game/systems/belt_reader.js b/src/js/game/systems/belt_reader.js index 4ce75af4..4f1b98dd 100644 --- a/src/js/game/systems/belt_reader.js +++ b/src/js/game/systems/belt_reader.js @@ -12,9 +12,9 @@ export class BeltReaderSystem extends GameSystemWithFilter { const now = this.root.time.now(); const minimumTime = now - globalConfig.readerAnalyzeIntervalSeconds; const minimumTimeForThroughput = now - 1; - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const readerComp = entity.components.BeltReader; const pinsComp = entity.components.WiredPins; diff --git a/src/js/game/systems/constant_signal.js b/src/js/game/systems/constant_signal.js index aaf31a19..33ba6f55 100644 --- a/src/js/game/systems/constant_signal.js +++ b/src/js/game/systems/constant_signal.js @@ -22,8 +22,8 @@ export class ConstantSignalSystem extends GameSystemWithFilter { update() { // Set signals - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const pinsComp = entity.components.WiredPins; const signalComp = entity.components.ConstantSignal; pinsComp.slots[0].value = signalComp.signal; diff --git a/src/js/game/systems/filter.js b/src/js/game/systems/filter.js index a6442b41..53675858 100644 --- a/src/js/game/systems/filter.js +++ b/src/js/game/systems/filter.js @@ -20,8 +20,8 @@ export class FilterSystem extends GameSystemWithFilter { const requiredProgress = 1 - progress; - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const filterComp = entity.components.Filter; const ejectorComp = entity.components.ItemEjector; diff --git a/src/js/game/systems/hub.js b/src/js/game/systems/hub.js index 2002b66e..a98eabc9 100644 --- a/src/js/game/systems/hub.js +++ b/src/js/game/systems/hub.js @@ -1,196 +1,185 @@ -import { globalConfig } from "../../core/config"; -import { smoothenDpi } from "../../core/dpi_manager"; -import { DrawParameters } from "../../core/draw_parameters"; -import { drawSpriteClipped } from "../../core/draw_utils"; -import { Loader } from "../../core/loader"; -import { Rectangle } from "../../core/rectangle"; -import { ORIGINAL_SPRITE_SCALE } from "../../core/sprites"; -import { formatBigNumber } from "../../core/utils"; -import { T } from "../../translations"; -import { HubComponent } from "../components/hub"; -import { Entity } from "../entity"; -import { GameSystemWithFilter } from "../game_system_with_filter"; - -const HUB_SIZE_TILES = 4; -const HUB_SIZE_PIXELS = HUB_SIZE_TILES * globalConfig.tileSize; - -export class HubSystem extends GameSystemWithFilter { - constructor(root) { - super(root, [HubComponent]); - - this.hubSprite = Loader.getSprite("sprites/buildings/hub.png"); - } - - /** - * @param {DrawParameters} parameters - */ - draw(parameters) { - for (let i = 0; i < this.allEntities.length; ++i) { - this.drawEntity(parameters, this.allEntities[i]); - } - } - - update() { - for (let i = 0; i < this.allEntities.length; ++i) { - // Set hub goal - const entity = this.allEntities[i]; - const pinsComp = entity.components.WiredPins; - pinsComp.slots[0].value = this.root.shapeDefinitionMgr.getShapeItemFromDefinition( - this.root.hubGoals.currentGoal.definition - ); - } - } - /** - * - * @param {HTMLCanvasElement} canvas - * @param {CanvasRenderingContext2D} context - * @param {number} w - * @param {number} h - * @param {number} dpi - */ - redrawHubBaseTexture(canvas, context, w, h, dpi) { - // This method is quite ugly, please ignore it! - - context.scale(dpi, dpi); - - const parameters = new DrawParameters({ - context, - visibleRect: new Rectangle(0, 0, w, h), - desiredAtlasScale: ORIGINAL_SPRITE_SCALE, - zoomLevel: dpi * 0.75, - root: this.root, - }); - - context.clearRect(0, 0, w, h); - - this.hubSprite.draw(context, 0, 0, w, h); - - if (this.root.hubGoals.isEndOfDemoReached()) { - // End of demo - context.font = "bold 12px GameFont"; - context.fillStyle = "#fd0752"; - context.textAlign = "center"; - context.fillText(T.buildings.hub.endOfDemo.toUpperCase(), w / 2, h / 2 + 6); - context.textAlign = "left"; - - return; - } - - const definition = this.root.hubGoals.currentGoal.definition; - definition.drawCentered(45, 58, parameters, 36); - - const goals = this.root.hubGoals.currentGoal; - - const textOffsetX = 70; - const textOffsetY = 61; - - if (goals.throughputOnly) { - // Throughput - const deliveredText = T.ingame.statistics.shapesDisplayUnits.second.replace( - "", - formatBigNumber(goals.required) - ); - - context.font = "bold 12px GameFont"; - context.fillStyle = "#64666e"; - context.textAlign = "left"; - context.fillText(deliveredText, textOffsetX, textOffsetY); - } else { - // Deliver count - const delivered = this.root.hubGoals.getCurrentGoalDelivered(); - const deliveredText = "" + formatBigNumber(delivered); - - if (delivered > 9999) { - context.font = "bold 16px GameFont"; - } else if (delivered > 999) { - context.font = "bold 20px GameFont"; - } else { - context.font = "bold 25px GameFont"; - } - context.fillStyle = "#64666e"; - context.textAlign = "left"; - context.fillText(deliveredText, textOffsetX, textOffsetY); - - // Required - context.font = "13px GameFont"; - context.fillStyle = "#a4a6b0"; - context.fillText("/ " + formatBigNumber(goals.required), textOffsetX, textOffsetY + 13); - } - - // Reward - const rewardText = T.storyRewards[goals.reward].title.toUpperCase(); - if (rewardText.length > 12) { - context.font = "bold 8px GameFont"; - } else { - context.font = "bold 10px GameFont"; - } - context.fillStyle = "#fd0752"; - context.textAlign = "center"; - - context.fillText(rewardText, HUB_SIZE_PIXELS / 2, 105); - - // Level "8" - context.font = "bold 10px GameFont"; - context.fillStyle = "#fff"; - context.fillText("" + this.root.hubGoals.level, 27, 32); - - // "LVL" - context.textAlign = "center"; - context.fillStyle = "#fff"; - context.font = "bold 6px GameFont"; - context.fillText(T.buildings.hub.levelShortcut, 27, 22); - - // "Deliver" - context.fillStyle = "#64666e"; - context.font = "bold 10px GameFont"; - context.fillText(T.buildings.hub.deliver.toUpperCase(), HUB_SIZE_PIXELS / 2, 30); - - // "To unlock" - const unlockText = T.buildings.hub.toUnlock.toUpperCase(); - if (unlockText.length > 15) { - context.font = "bold 8px GameFont"; - } else { - context.font = "bold 10px GameFont"; - } - context.fillText(T.buildings.hub.toUnlock.toUpperCase(), HUB_SIZE_PIXELS / 2, 92); - - context.textAlign = "left"; - } - - /** - * @param {DrawParameters} parameters - * @param {Entity} entity - */ - drawEntity(parameters, entity) { - const staticComp = entity.components.StaticMapEntity; - if (!staticComp.shouldBeDrawn(parameters)) { - return; - } - - // Deliver count - const delivered = this.root.hubGoals.getCurrentGoalDelivered(); - const deliveredText = "" + formatBigNumber(delivered); - - const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); - const canvas = parameters.root.buffers.getForKey({ - key: "hub", - subKey: dpi + "/" + this.root.hubGoals.level + "/" + deliveredText, - w: globalConfig.tileSize * 4, - h: globalConfig.tileSize * 4, - dpi, - redrawMethod: this.redrawHubBaseTexture.bind(this), - }); - - const extrude = 8; - drawSpriteClipped({ - parameters, - sprite: canvas, - x: staticComp.origin.x * globalConfig.tileSize - extrude, - y: staticComp.origin.y * globalConfig.tileSize - extrude, - w: HUB_SIZE_PIXELS + 2 * extrude, - h: HUB_SIZE_PIXELS + 2 * extrude, - originalW: HUB_SIZE_PIXELS * dpi, - originalH: HUB_SIZE_PIXELS * dpi, - }); - } -} +import { globalConfig } from "../../core/config"; +import { smoothenDpi } from "../../core/dpi_manager"; +import { DrawParameters } from "../../core/draw_parameters"; +import { drawSpriteClipped } from "../../core/draw_utils"; +import { Loader } from "../../core/loader"; +import { Rectangle } from "../../core/rectangle"; +import { ORIGINAL_SPRITE_SCALE } from "../../core/sprites"; +import { formatBigNumber } from "../../core/utils"; +import { T } from "../../translations"; +import { HubComponent } from "../components/hub"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; + +const HUB_SIZE_TILES = 4; +const HUB_SIZE_PIXELS = HUB_SIZE_TILES * globalConfig.tileSize; + +export class HubSystem extends GameSystemWithFilter { + constructor(root) { + super(root, [HubComponent]); + + this.hubSprite = Loader.getSprite("sprites/buildings/hub.png"); + } + + /** + * @param {DrawParameters} parameters + */ + draw(parameters) { + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; + this.drawEntity(parameters, entity); + } + } + + update() { + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; + const pinsComp = entity.components.WiredPins; + pinsComp.slots[0].value = this.root.shapeDefinitionMgr.getShapeItemFromDefinition( + this.root.hubGoals.currentGoal.definition + ); + } + } + /** + * + * @param {HTMLCanvasElement} canvas + * @param {CanvasRenderingContext2D} context + * @param {number} w + * @param {number} h + * @param {number} dpi + */ + redrawHubBaseTexture(canvas, context, w, h, dpi) { + // This method is quite ugly, please ignore it! + + context.scale(dpi, dpi); + + const parameters = new DrawParameters({ + context, + visibleRect: new Rectangle(0, 0, w, h), + desiredAtlasScale: ORIGINAL_SPRITE_SCALE, + zoomLevel: dpi * 0.75, + root: this.root, + }); + + context.clearRect(0, 0, w, h); + + this.hubSprite.draw(context, 0, 0, w, h); + + const definition = this.root.hubGoals.currentGoal.definition; + definition.drawCentered(45, 58, parameters, 36); + + const goals = this.root.hubGoals.currentGoal; + + const textOffsetX = 70; + const textOffsetY = 61; + + if (goals.throughputOnly) { + // Throughput + const deliveredText = T.ingame.statistics.shapesDisplayUnits.second.replace( + "", + formatBigNumber(goals.required) + ); + + context.font = "bold 12px GameFont"; + context.fillStyle = "#64666e"; + context.textAlign = "left"; + context.fillText(deliveredText, textOffsetX, textOffsetY); + } else { + // Deliver count + const delivered = this.root.hubGoals.getCurrentGoalDelivered(); + const deliveredText = "" + formatBigNumber(delivered); + + if (delivered > 9999) { + context.font = "bold 16px GameFont"; + } else if (delivered > 999) { + context.font = "bold 20px GameFont"; + } else { + context.font = "bold 25px GameFont"; + } + context.fillStyle = "#64666e"; + context.textAlign = "left"; + context.fillText(deliveredText, textOffsetX, textOffsetY); + + // Required + context.font = "13px GameFont"; + context.fillStyle = "#a4a6b0"; + context.fillText("/ " + formatBigNumber(goals.required), textOffsetX, textOffsetY + 13); + + // Reward + const rewardText = T.storyRewards[goals.reward].title.toUpperCase(); + if (rewardText.length > 12) { + context.font = "bold 8px GameFont"; + } else { + context.font = "bold 10px GameFont"; + } + context.fillStyle = "#fd0752"; + context.textAlign = "center"; + + context.fillText(rewardText, HUB_SIZE_PIXELS / 2, 105); + + // Level "8" + context.font = "bold 10px GameFont"; + context.fillStyle = "#fff"; + context.fillText("" + this.root.hubGoals.level, 27, 32); + + // "LVL" + context.textAlign = "center"; + context.fillStyle = "#fff"; + context.font = "bold 6px GameFont"; + context.fillText(T.buildings.hub.levelShortcut, 27, 22); + + // "Deliver" + context.fillStyle = "#64666e"; + context.font = "bold 10px GameFont"; + context.fillText(T.buildings.hub.deliver.toUpperCase(), HUB_SIZE_PIXELS / 2, 30); + + // "To unlock" + const unlockText = T.buildings.hub.toUnlock.toUpperCase(); + if (unlockText.length > 15) { + context.font = "bold 8px GameFont"; + } else { + context.font = "bold 10px GameFont"; + } + context.fillText(T.buildings.hub.toUnlock.toUpperCase(), HUB_SIZE_PIXELS / 2, 92); + + context.textAlign = "left"; + } + } + + /** + * @param {DrawParameters} parameters + * @param {Entity} entity + */ + drawEntity(parameters, entity) { + const staticComp = entity.components.StaticMapEntity; + if (!staticComp.shouldBeDrawn(parameters)) { + return; + } + + // Deliver count + const delivered = this.root.hubGoals.getCurrentGoalDelivered(); + const deliveredText = "" + formatBigNumber(delivered); + + const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); + const canvas = parameters.root.buffers.getForKey({ + key: "hub", + subKey: dpi + "/" + this.root.hubGoals.level + "/" + deliveredText, + w: globalConfig.tileSize * 4, + h: globalConfig.tileSize * 4, + dpi, + redrawMethod: this.redrawHubBaseTexture.bind(this), + }); + + const extrude = 8; + drawSpriteClipped({ + parameters, + sprite: canvas, + x: staticComp.origin.x * globalConfig.tileSize - extrude, + y: staticComp.origin.y * globalConfig.tileSize - extrude, + w: HUB_SIZE_PIXELS + 2 * extrude, + h: HUB_SIZE_PIXELS + 2 * extrude, + originalW: HUB_SIZE_PIXELS * dpi, + originalH: HUB_SIZE_PIXELS * dpi, + }); + } +} diff --git a/src/js/game/systems/item_acceptor.js b/src/js/game/systems/item_acceptor.js index 780b4abd..8d0977d4 100644 --- a/src/js/game/systems/item_acceptor.js +++ b/src/js/game/systems/item_acceptor.js @@ -39,8 +39,8 @@ export class ItemAcceptorSystem extends GameSystemWithFilter { // Reset accumulated ticks this.accumulatedTicksWhileInMapOverview = 0; - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const aceptorComp = entity.components.ItemAcceptor; const animations = aceptorComp.itemConsumptionAnimations; diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index 56535111..8f4eab52 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -3,6 +3,7 @@ import { DrawParameters } from "../../core/draw_parameters"; import { createLogger } from "../../core/logging"; import { Rectangle } from "../../core/rectangle"; import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { dirInterval } from "../../core/utils"; import { enumDirection, enumDirectionToVector } from "../../core/vector"; import { BaseItem } from "../base_item"; import { BeltComponent } from "../components/belt"; @@ -60,8 +61,8 @@ export class ItemEjectorSystem extends GameSystemWithFilter { */ recomputeCacheFull() { logger.log("Full cache recompute in post load hook"); - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; this.recomputeSingleEntityCache(entity); } } @@ -146,8 +147,8 @@ export class ItemEjectorSystem extends GameSystemWithFilter { } // Go over all cache entries - for (let i = 0; i < this.allEntities.length; ++i) { - const sourceEntity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const sourceEntity = this.allEntitiesArray[i]; const sourceEjectorComp = sourceEntity.components.ItemEjector; const slots = sourceEjectorComp.slots; diff --git a/src/js/game/systems/item_processor.js b/src/js/game/systems/item_processor.js index 9775afde..20ea5d04 100644 --- a/src/js/game/systems/item_processor.js +++ b/src/js/game/systems/item_processor.js @@ -68,9 +68,8 @@ export class ItemProcessorSystem extends GameSystemWithFilter { } update() { - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const processorComp = entity.components.ItemProcessor; const ejectorComp = entity.components.ItemEjector; diff --git a/src/js/game/systems/item_producer.js b/src/js/game/systems/item_producer.js index 52edf5d1..eafd0a7a 100644 --- a/src/js/game/systems/item_producer.js +++ b/src/js/game/systems/item_producer.js @@ -7,8 +7,8 @@ export class ItemProducerSystem extends GameSystemWithFilter { } update() { - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const pinsComp = entity.components.WiredPins; const pin = pinsComp.slots[0]; const network = pin.linkedNetwork; diff --git a/src/js/game/systems/lever.js b/src/js/game/systems/lever.js index 75b6cf28..0997f6a1 100644 --- a/src/js/game/systems/lever.js +++ b/src/js/game/systems/lever.js @@ -14,9 +14,8 @@ export class LeverSystem extends GameSystemWithFilter { } update() { - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const leverComp = entity.components.Lever; const pinsComp = entity.components.WiredPins; diff --git a/src/js/game/systems/logic_gate.js b/src/js/game/systems/logic_gate.js index 4545a331..d426e537 100644 --- a/src/js/game/systems/logic_gate.js +++ b/src/js/game/systems/logic_gate.js @@ -30,8 +30,8 @@ export class LogicGateSystem extends GameSystemWithFilter { } update() { - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const logicComp = entity.components.LogicGate; const slotComp = entity.components.WiredPins; diff --git a/src/js/game/systems/miner.js b/src/js/game/systems/miner.js index cd478be3..1b4734df 100644 --- a/src/js/game/systems/miner.js +++ b/src/js/game/systems/miner.js @@ -36,8 +36,8 @@ export class MinerSystem extends GameSystemWithFilter { miningSpeed *= 100; } - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; const minerComp = entity.components.Miner; // Reset everything on recompute diff --git a/src/js/game/systems/storage.js b/src/js/game/systems/storage.js index 80affac9..69eb5a3f 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 = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[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/game/systems/underground_belt.js b/src/js/game/systems/underground_belt.js index 7a7609f8..8e736441 100644 --- a/src/js/game/systems/underground_belt.js +++ b/src/js/game/systems/underground_belt.js @@ -1,349 +1,348 @@ -import { globalConfig } from "../../core/config"; -import { Loader } from "../../core/loader"; -import { createLogger } from "../../core/logging"; -import { Rectangle } from "../../core/rectangle"; -import { StaleAreaDetector } from "../../core/stale_area_detector"; -import { fastArrayDelete } from "../../core/utils"; -import { - enumAngleToDirection, - enumDirection, - enumDirectionToAngle, - enumDirectionToVector, - enumInvertedDirections, -} from "../../core/vector"; -import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt"; -import { Entity } from "../entity"; -import { GameSystemWithFilter } from "../game_system_with_filter"; - -const logger = createLogger("tunnels"); - -export class UndergroundBeltSystem extends GameSystemWithFilter { - constructor(root) { - super(root, [UndergroundBeltComponent]); - - this.beltSprites = { - [enumUndergroundBeltMode.sender]: Loader.getSprite( - "sprites/buildings/underground_belt_entry.png" - ), - [enumUndergroundBeltMode.receiver]: Loader.getSprite( - "sprites/buildings/underground_belt_exit.png" - ), - }; - - this.staleAreaWatcher = new StaleAreaDetector({ - root: this.root, - name: "underground-belt", - recomputeMethod: this.recomputeArea.bind(this), - }); - - this.root.signals.entityManuallyPlaced.add(this.onEntityManuallyPlaced, this); - - // NOTICE: Once we remove a tunnel, we need to update the whole area to - // clear outdated handles - this.staleAreaWatcher.recomputeOnComponentsChanged( - [UndergroundBeltComponent], - globalConfig.undergroundBeltMaxTilesByTier[globalConfig.undergroundBeltMaxTilesByTier.length - 1] - ); - } - - /** - * Callback when an entity got placed, used to remove belts between underground belts - * @param {Entity} entity - */ - onEntityManuallyPlaced(entity) { - if (!this.root.app.settings.getAllSettings().enableTunnelSmartplace) { - // Smart-place disabled - return; - } - - const undergroundComp = entity.components.UndergroundBelt; - if (undergroundComp && undergroundComp.mode === enumUndergroundBeltMode.receiver) { - const staticComp = entity.components.StaticMapEntity; - const tile = staticComp.origin; - - const direction = enumAngleToDirection[staticComp.rotation]; - const inverseDirection = enumInvertedDirections[direction]; - const offset = enumDirectionToVector[inverseDirection]; - - let currentPos = tile.copy(); - - const tier = undergroundComp.tier; - const range = globalConfig.undergroundBeltMaxTilesByTier[tier]; - - // FIND ENTRANCE - // Search for the entrance which is farthest apart (this is why we can't reuse logic here) - let matchingEntrance = null; - for (let i = 0; i < range; ++i) { - currentPos.addInplace(offset); - const contents = this.root.map.getTileContent(currentPos, entity.layer); - if (!contents) { - continue; - } - - const contentsUndergroundComp = contents.components.UndergroundBelt; - const contentsStaticComp = contents.components.StaticMapEntity; - if ( - contentsUndergroundComp && - contentsUndergroundComp.tier === undergroundComp.tier && - contentsUndergroundComp.mode === enumUndergroundBeltMode.sender && - enumAngleToDirection[contentsStaticComp.rotation] === direction - ) { - matchingEntrance = { - entity: contents, - range: i, - }; - } - } - - if (!matchingEntrance) { - // Nothing found - return; - } - - // DETECT OBSOLETE BELTS BETWEEN - // Remove any belts between entrance and exit which have the same direction, - // but only if they *all* have the right direction - currentPos = tile.copy(); - let allBeltsMatch = true; - for (let i = 0; i < matchingEntrance.range; ++i) { - currentPos.addInplace(offset); - - const contents = this.root.map.getTileContent(currentPos, entity.layer); - if (!contents) { - allBeltsMatch = false; - break; - } - - const contentsStaticComp = contents.components.StaticMapEntity; - const contentsBeltComp = contents.components.Belt; - if (!contentsBeltComp) { - allBeltsMatch = false; - break; - } - - // It's a belt - if ( - contentsBeltComp.direction !== enumDirection.top || - enumAngleToDirection[contentsStaticComp.rotation] !== direction - ) { - allBeltsMatch = false; - break; - } - } - - currentPos = tile.copy(); - if (allBeltsMatch) { - // All belts between this are obsolete, so drop them - for (let i = 0; i < matchingEntrance.range; ++i) { - currentPos.addInplace(offset); - const contents = this.root.map.getTileContent(currentPos, entity.layer); - assert(contents, "Invalid smart underground belt logic"); - this.root.logic.tryDeleteBuilding(contents); - } - } - - // REMOVE OBSOLETE TUNNELS - // Remove any double tunnels, by checking the tile plus the tile above - currentPos = tile.copy().add(offset); - for (let i = 0; i < matchingEntrance.range - 1; ++i) { - const posBefore = currentPos.copy(); - currentPos.addInplace(offset); - - const entityBefore = this.root.map.getTileContent(posBefore, entity.layer); - const entityAfter = this.root.map.getTileContent(currentPos, entity.layer); - - if (!entityBefore || !entityAfter) { - continue; - } - - const undergroundBefore = entityBefore.components.UndergroundBelt; - const undergroundAfter = entityAfter.components.UndergroundBelt; - - if (!undergroundBefore || !undergroundAfter) { - // Not an underground belt - continue; - } - - if ( - // Both same tier - undergroundBefore.tier !== undergroundAfter.tier || - // And same tier as our original entity - undergroundBefore.tier !== undergroundComp.tier - ) { - // Mismatching tier - continue; - } - - if ( - undergroundBefore.mode !== enumUndergroundBeltMode.sender || - undergroundAfter.mode !== enumUndergroundBeltMode.receiver - ) { - // Not the right mode - continue; - } - - // Check rotations - const staticBefore = entityBefore.components.StaticMapEntity; - const staticAfter = entityAfter.components.StaticMapEntity; - - if ( - enumAngleToDirection[staticBefore.rotation] !== direction || - enumAngleToDirection[staticAfter.rotation] !== direction - ) { - // Wrong rotation - continue; - } - - // All good, can remove - this.root.logic.tryDeleteBuilding(entityBefore); - this.root.logic.tryDeleteBuilding(entityAfter); - } - } - } - - /** - * Recomputes the cache in the given area, invalidating all entries there - * @param {Rectangle} area - */ - recomputeArea(area) { - for (let x = area.x; x < area.right(); ++x) { - for (let y = area.y; y < area.bottom(); ++y) { - const entities = this.root.map.getLayersContentsMultipleXY(x, y); - for (let i = 0; i < entities.length; ++i) { - const entity = entities[i]; - const undergroundComp = entity.components.UndergroundBelt; - if (!undergroundComp) { - continue; - } - undergroundComp.cachedLinkedEntity = null; - } - } - } - } - - update() { - this.staleAreaWatcher.update(); - - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - const undergroundComp = entity.components.UndergroundBelt; - if (undergroundComp.mode === enumUndergroundBeltMode.sender) { - this.handleSender(entity); - } else { - this.handleReceiver(entity); - } - } - } - - /** - * Finds the receiver for a given sender - * @param {Entity} entity - * @returns {import("../components/underground_belt").LinkedUndergroundBelt} - */ - findRecieverForSender(entity) { - const staticComp = entity.components.StaticMapEntity; - const undergroundComp = entity.components.UndergroundBelt; - const searchDirection = staticComp.localDirectionToWorld(enumDirection.top); - const searchVector = enumDirectionToVector[searchDirection]; - const targetRotation = enumDirectionToAngle[searchDirection]; - let currentTile = staticComp.origin; - - // Search in the direction of the tunnel - for ( - let searchOffset = 0; - searchOffset < globalConfig.undergroundBeltMaxTilesByTier[undergroundComp.tier]; - ++searchOffset - ) { - currentTile = currentTile.add(searchVector); - - const potentialReceiver = this.root.map.getTileContent(currentTile, "regular"); - if (!potentialReceiver) { - // Empty tile - continue; - } - const receiverUndergroundComp = potentialReceiver.components.UndergroundBelt; - if (!receiverUndergroundComp || receiverUndergroundComp.tier !== undergroundComp.tier) { - // Not a tunnel, or not on the same tier - continue; - } - - const receiverStaticComp = potentialReceiver.components.StaticMapEntity; - if (receiverStaticComp.rotation !== targetRotation) { - // Wrong rotation - continue; - } - - if (receiverUndergroundComp.mode !== enumUndergroundBeltMode.receiver) { - // Not a receiver, but a sender -> Abort to make sure we don't deliver double - break; - } - - return { entity: potentialReceiver, distance: searchOffset }; - } - - // None found - return { entity: null, distance: 0 }; - } - - /** - * - * @param {Entity} entity - */ - handleSender(entity) { - const undergroundComp = entity.components.UndergroundBelt; - - // Find the current receiver - let cacheEntry = undergroundComp.cachedLinkedEntity; - if (!cacheEntry) { - // Need to recompute cache - cacheEntry = undergroundComp.cachedLinkedEntity = this.findRecieverForSender(entity); - } - - if (!cacheEntry.entity) { - // If there is no connection to a receiver, ignore this one - return; - } - - // Check if we have any items to eject - const nextItemAndDuration = undergroundComp.pendingItems[0]; - if (nextItemAndDuration) { - assert(undergroundComp.pendingItems.length === 1, "more than 1 pending"); - - // Check if the receiver can accept it - if ( - cacheEntry.entity.components.UndergroundBelt.tryAcceptTunneledItem( - nextItemAndDuration[0], - cacheEntry.distance, - this.root.hubGoals.getUndergroundBeltBaseSpeed(), - this.root.time.now() - ) - ) { - // Drop this item - fastArrayDelete(undergroundComp.pendingItems, 0); - } - } - } - - /** - * - * @param {Entity} entity - */ - handleReceiver(entity) { - const undergroundComp = entity.components.UndergroundBelt; - - // Try to eject items, we only check the first one because it is sorted by remaining time - const nextItemAndDuration = undergroundComp.pendingItems[0]; - if (nextItemAndDuration) { - if (this.root.time.now() > nextItemAndDuration[1]) { - const ejectorComp = entity.components.ItemEjector; - - const nextSlotIndex = ejectorComp.getFirstFreeSlot(); - if (nextSlotIndex !== null) { - if (ejectorComp.tryEject(nextSlotIndex, nextItemAndDuration[0])) { - undergroundComp.pendingItems.shift(); - } - } - } - } - } -} +import { globalConfig } from "../../core/config"; +import { Loader } from "../../core/loader"; +import { createLogger } from "../../core/logging"; +import { Rectangle } from "../../core/rectangle"; +import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { fastArrayDelete } from "../../core/utils"; +import { + enumAngleToDirection, + enumDirection, + enumDirectionToAngle, + enumDirectionToVector, + enumInvertedDirections, +} from "../../core/vector"; +import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; + +const logger = createLogger("tunnels"); + +export class UndergroundBeltSystem extends GameSystemWithFilter { + constructor(root) { + super(root, [UndergroundBeltComponent]); + + this.beltSprites = { + [enumUndergroundBeltMode.sender]: Loader.getSprite( + "sprites/buildings/underground_belt_entry.png" + ), + [enumUndergroundBeltMode.receiver]: Loader.getSprite( + "sprites/buildings/underground_belt_exit.png" + ), + }; + + this.staleAreaWatcher = new StaleAreaDetector({ + root: this.root, + name: "underground-belt", + recomputeMethod: this.recomputeArea.bind(this), + }); + + this.root.signals.entityManuallyPlaced.add(this.onEntityManuallyPlaced, this); + + // NOTICE: Once we remove a tunnel, we need to update the whole area to + // clear outdated handles + this.staleAreaWatcher.recomputeOnComponentsChanged( + [UndergroundBeltComponent], + globalConfig.undergroundBeltMaxTilesByTier[globalConfig.undergroundBeltMaxTilesByTier.length - 1] + ); + } + + /** + * Callback when an entity got placed, used to remove belts between underground belts + * @param {Entity} entity + */ + onEntityManuallyPlaced(entity) { + if (!this.root.app.settings.getAllSettings().enableTunnelSmartplace) { + // Smart-place disabled + return; + } + + const undergroundComp = entity.components.UndergroundBelt; + if (undergroundComp && undergroundComp.mode === enumUndergroundBeltMode.receiver) { + const staticComp = entity.components.StaticMapEntity; + const tile = staticComp.origin; + + const direction = enumAngleToDirection[staticComp.rotation]; + const inverseDirection = enumInvertedDirections[direction]; + const offset = enumDirectionToVector[inverseDirection]; + + let currentPos = tile.copy(); + + const tier = undergroundComp.tier; + const range = globalConfig.undergroundBeltMaxTilesByTier[tier]; + + // FIND ENTRANCE + // Search for the entrance which is farthest apart (this is why we can't reuse logic here) + let matchingEntrance = null; + for (let i = 0; i < range; ++i) { + currentPos.addInplace(offset); + const contents = this.root.map.getTileContent(currentPos, entity.layer); + if (!contents) { + continue; + } + + const contentsUndergroundComp = contents.components.UndergroundBelt; + const contentsStaticComp = contents.components.StaticMapEntity; + if ( + contentsUndergroundComp && + contentsUndergroundComp.tier === undergroundComp.tier && + contentsUndergroundComp.mode === enumUndergroundBeltMode.sender && + enumAngleToDirection[contentsStaticComp.rotation] === direction + ) { + matchingEntrance = { + entity: contents, + range: i, + }; + } + } + + if (!matchingEntrance) { + // Nothing found + return; + } + + // DETECT OBSOLETE BELTS BETWEEN + // Remove any belts between entrance and exit which have the same direction, + // but only if they *all* have the right direction + currentPos = tile.copy(); + let allBeltsMatch = true; + for (let i = 0; i < matchingEntrance.range; ++i) { + currentPos.addInplace(offset); + + const contents = this.root.map.getTileContent(currentPos, entity.layer); + if (!contents) { + allBeltsMatch = false; + break; + } + + const contentsStaticComp = contents.components.StaticMapEntity; + const contentsBeltComp = contents.components.Belt; + if (!contentsBeltComp) { + allBeltsMatch = false; + break; + } + + // It's a belt + if ( + contentsBeltComp.direction !== enumDirection.top || + enumAngleToDirection[contentsStaticComp.rotation] !== direction + ) { + allBeltsMatch = false; + break; + } + } + + currentPos = tile.copy(); + if (allBeltsMatch) { + // All belts between this are obsolete, so drop them + for (let i = 0; i < matchingEntrance.range; ++i) { + currentPos.addInplace(offset); + const contents = this.root.map.getTileContent(currentPos, entity.layer); + assert(contents, "Invalid smart underground belt logic"); + this.root.logic.tryDeleteBuilding(contents); + } + } + + // REMOVE OBSOLETE TUNNELS + // Remove any double tunnels, by checking the tile plus the tile above + currentPos = tile.copy().add(offset); + for (let i = 0; i < matchingEntrance.range - 1; ++i) { + const posBefore = currentPos.copy(); + currentPos.addInplace(offset); + + const entityBefore = this.root.map.getTileContent(posBefore, entity.layer); + const entityAfter = this.root.map.getTileContent(currentPos, entity.layer); + + if (!entityBefore || !entityAfter) { + continue; + } + + const undergroundBefore = entityBefore.components.UndergroundBelt; + const undergroundAfter = entityAfter.components.UndergroundBelt; + + if (!undergroundBefore || !undergroundAfter) { + // Not an underground belt + continue; + } + + if ( + // Both same tier + undergroundBefore.tier !== undergroundAfter.tier || + // And same tier as our original entity + undergroundBefore.tier !== undergroundComp.tier + ) { + // Mismatching tier + continue; + } + + if ( + undergroundBefore.mode !== enumUndergroundBeltMode.sender || + undergroundAfter.mode !== enumUndergroundBeltMode.receiver + ) { + // Not the right mode + continue; + } + + // Check rotations + const staticBefore = entityBefore.components.StaticMapEntity; + const staticAfter = entityAfter.components.StaticMapEntity; + + if ( + enumAngleToDirection[staticBefore.rotation] !== direction || + enumAngleToDirection[staticAfter.rotation] !== direction + ) { + // Wrong rotation + continue; + } + + // All good, can remove + this.root.logic.tryDeleteBuilding(entityBefore); + this.root.logic.tryDeleteBuilding(entityAfter); + } + } + } + + /** + * Recomputes the cache in the given area, invalidating all entries there + * @param {Rectangle} area + */ + recomputeArea(area) { + for (let x = area.x; x < area.right(); ++x) { + for (let y = area.y; y < area.bottom(); ++y) { + const entities = this.root.map.getLayersContentsMultipleXY(x, y); + for (let i = 0; i < entities.length; ++i) { + const entity = entities[i]; + const undergroundComp = entity.components.UndergroundBelt; + if (!undergroundComp) { + continue; + } + undergroundComp.cachedLinkedEntity = null; + } + } + } + } + + update() { + this.staleAreaWatcher.update(); + for (let i = this.allEntitiesArray.length - 1; i >= 0; --i) { + const entity = this.allEntitiesArray[i]; + const undergroundComp = entity.components.UndergroundBelt; + if (undergroundComp.mode === enumUndergroundBeltMode.sender) { + this.handleSender(entity); + } else { + this.handleReceiver(entity); + } + } + } + + /** + * Finds the receiver for a given sender + * @param {Entity} entity + * @returns {import("../components/underground_belt").LinkedUndergroundBelt} + */ + findRecieverForSender(entity) { + const staticComp = entity.components.StaticMapEntity; + const undergroundComp = entity.components.UndergroundBelt; + const searchDirection = staticComp.localDirectionToWorld(enumDirection.top); + const searchVector = enumDirectionToVector[searchDirection]; + const targetRotation = enumDirectionToAngle[searchDirection]; + let currentTile = staticComp.origin; + + // Search in the direction of the tunnel + for ( + let searchOffset = 0; + searchOffset < globalConfig.undergroundBeltMaxTilesByTier[undergroundComp.tier]; + ++searchOffset + ) { + currentTile = currentTile.add(searchVector); + + const potentialReceiver = this.root.map.getTileContent(currentTile, "regular"); + if (!potentialReceiver) { + // Empty tile + continue; + } + const receiverUndergroundComp = potentialReceiver.components.UndergroundBelt; + if (!receiverUndergroundComp || receiverUndergroundComp.tier !== undergroundComp.tier) { + // Not a tunnel, or not on the same tier + continue; + } + + const receiverStaticComp = potentialReceiver.components.StaticMapEntity; + if (receiverStaticComp.rotation !== targetRotation) { + // Wrong rotation + continue; + } + + if (receiverUndergroundComp.mode !== enumUndergroundBeltMode.receiver) { + // Not a receiver, but a sender -> Abort to make sure we don't deliver double + break; + } + + return { entity: potentialReceiver, distance: searchOffset }; + } + + // None found + return { entity: null, distance: 0 }; + } + + /** + * + * @param {Entity} entity + */ + handleSender(entity) { + const undergroundComp = entity.components.UndergroundBelt; + + // Find the current receiver + let cacheEntry = undergroundComp.cachedLinkedEntity; + if (!cacheEntry) { + // Need to recompute cache + cacheEntry = undergroundComp.cachedLinkedEntity = this.findRecieverForSender(entity); + } + + if (!cacheEntry.entity) { + // If there is no connection to a receiver, ignore this one + return; + } + + // Check if we have any items to eject + const nextItemAndDuration = undergroundComp.pendingItems[0]; + if (nextItemAndDuration) { + assert(undergroundComp.pendingItems.length === 1, "more than 1 pending"); + + // Check if the receiver can accept it + if ( + cacheEntry.entity.components.UndergroundBelt.tryAcceptTunneledItem( + nextItemAndDuration[0], + cacheEntry.distance, + this.root.hubGoals.getUndergroundBeltBaseSpeed(), + this.root.time.now() + ) + ) { + // Drop this item + fastArrayDelete(undergroundComp.pendingItems, 0); + } + } + } + + /** + * + * @param {Entity} entity + */ + handleReceiver(entity) { + const undergroundComp = entity.components.UndergroundBelt; + + // Try to eject items, we only check the first one because it is sorted by remaining time + const nextItemAndDuration = undergroundComp.pendingItems[0]; + if (nextItemAndDuration) { + if (this.root.time.now() > nextItemAndDuration[1]) { + const ejectorComp = entity.components.ItemEjector; + + const nextSlotIndex = ejectorComp.getFirstFreeSlot(); + if (nextSlotIndex !== null) { + if (ejectorComp.tryEject(nextSlotIndex, nextItemAndDuration[0])) { + undergroundComp.pendingItems.shift(); + } + } + } + } + } +} diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index 0ad630f6..1316cb91 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -1,3 +1,4 @@ +// @ts-nocheck import { ReadWriteProxy } from "../core/read_write_proxy"; import { ExplainedResult } from "../core/explained_result"; import { SavegameSerializer } from "./savegame_serializer"; @@ -168,7 +169,10 @@ export class Savegame extends ReadWriteProxy { * Returns if this game has a serialized game dump */ hasGameDump() { - return !!this.currentData.dump && this.currentData.dump.entities.length > 0; + return ( + !!this.currentData.dump && + (this.currentData.dump.entities.length > 0 || this.currentData.dump.entities.size > 0) + ); } /** diff --git a/src/js/savegame/savegame_serializer.js b/src/js/savegame/savegame_serializer.js index c1247225..11e1e69c 100644 --- a/src/js/savegame/savegame_serializer.js +++ b/src/js/savegame/savegame_serializer.js @@ -67,9 +67,10 @@ export class SavegameSerializer { const seenUids = new Set(); // Check for duplicate UIDS - for (let i = 0; i < savegame.entities.length; ++i) { + const entities = [...savegame.entities.values()]; + for (let i = 0; i < entities.length; ++i) { /** @type {Entity} */ - const entity = savegame.entities[i]; + const entity = entities[i]; const uid = entity.uid; if (!Number.isInteger(uid)) { @@ -133,7 +134,8 @@ export class SavegameSerializer { errorReason = errorReason || root.hubGoals.deserialize(savegame.hubGoals, root); 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 || this.internal.deserializeEntityArray(root, [...savegame.entities.values()]); errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths); // Check for errors diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index 0f94cd6a..f310c664 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -11,7 +11,7 @@ * hubGoals: any, * pinnedShapes: any, * waypoints: any, - * entities: Array, + * entities: Array|Set, * beltPaths: Array * }} SerializedGame * diff --git a/src/js/savegame/schemas/1001.js b/src/js/savegame/schemas/1001.js index af86b09d..658a5a58 100644 --- a/src/js/savegame/schemas/1001.js +++ b/src/js/savegame/schemas/1001.js @@ -40,7 +40,7 @@ export class SavegameInterface_V1001 extends SavegameInterface_V1000 { ], }; - const entities = dump.entities; + const entities = Array.isArray(dump.entities) ? dump.entities : [...dump.entities.values()]; for (let i = 0; i < entities.length; ++i) { const entity = entities[i]; diff --git a/src/js/savegame/schemas/1002.js b/src/js/savegame/schemas/1002.js index 866bc1e8..e2351582 100644 --- a/src/js/savegame/schemas/1002.js +++ b/src/js/savegame/schemas/1002.js @@ -24,7 +24,7 @@ export class SavegameInterface_V1002 extends SavegameInterface_V1001 { return true; } - const entities = dump.entities; + const entities = Array.isArray(dump.entities) ? dump.entities : [...dump.entities.values()]; for (let i = 0; i < entities.length; ++i) { const entity = entities[i]; const beltComp = entity.components.Belt; diff --git a/src/js/savegame/schemas/1005.js b/src/js/savegame/schemas/1005.js index 0380f8eb..1949ee0e 100644 --- a/src/js/savegame/schemas/1005.js +++ b/src/js/savegame/schemas/1005.js @@ -26,7 +26,7 @@ export class SavegameInterface_V1005 extends SavegameInterface_V1004 { // just reset belt paths for now dump.beltPaths = []; - const entities = dump.entities; + const entities = Array.isArray(dump.entities) ? dump.entities : [...dump.entities.values()]; // clear ejector slots for (let i = 0; i < entities.length; ++i) { diff --git a/src/js/savegame/schemas/1006.js b/src/js/savegame/schemas/1006.js index 79226772..5a9fd4d8 100644 --- a/src/js/savegame/schemas/1006.js +++ b/src/js/savegame/schemas/1006.js @@ -173,7 +173,8 @@ export class SavegameInterface_V1006 extends SavegameInterface_V1005 { dump.hubGoals.level = levelMapping[level] || level; // Update entities - const entities = dump.entities; + const entities = Array.isArray(dump.entities) ? dump.entities : [...dump.entities.values()]; + for (let i = 0; i < entities.length; ++i) { const entity = entities[i]; const components = entity.components; @@ -269,8 +270,17 @@ export class SavegameInterface_V1006 extends SavegameInterface_V1005 { newStaticComp.originalRotation = staticComp.originalRotation; newStaticComp.rotation = staticComp.rotation; + /** + * in one of our files: + * we dont seem to actually have a blueprintspritekey + * but we do have this attribute called code + */ + // @ts-ignore - newStaticComp.code = spriteMapping[staticComp.blueprintSpriteKey]; + if (staticComp.blueprintSpriteKey) { + // @ts-ignore + newStaticComp.code = spriteMapping[staticComp.blueprintSpriteKey]; + } else newStaticComp.code = staticComp.code; // Hub special case if (entity.components.Hub) { @@ -293,9 +303,11 @@ export class SavegameInterface_V1006 extends SavegameInterface_V1005 { } if (!newStaticComp.code) { + console.dir(entity); + console.dir(staticComp); throw new Error( // @ts-ignore - "1006 Migration: Could not reconstruct code for " + staticComp.blueprintSpriteKey + "1006 Migration: Could not reconstruct code for " + code ); } diff --git a/src/js/savegame/serializer_internal.js b/src/js/savegame/serializer_internal.js index c75cebad..7ed1ee32 100644 --- a/src/js/savegame/serializer_internal.js +++ b/src/js/savegame/serializer_internal.js @@ -11,12 +11,15 @@ const logger = createLogger("serializer_internal"); export class SerializerInternal { /** * Serializes an array of entities - * @param {Array} array + * @param {Array|Set} array */ serializeEntityArray(array) { const serialized = []; - for (let i = 0; i < array.length; ++i) { - const entity = array[i]; + + const arr = Array.isArray(array) ? array : [...array.values()]; + + for (let i = 0; i < arr.length; ++i) { + const entity = arr[i]; if (!entity.queuedForDestroy && !entity.destroyed) { serialized.push(entity.serialize()); }