diff --git a/src/js/changelog.js b/src/js/changelog.js index d4751f5b..29b5a28d 100644 --- a/src/js/changelog.js +++ b/src/js/changelog.js @@ -1,8 +1,9 @@ export const CHANGELOG = [ { version: "1.1.18", - date: "24.06.2020", + date: "26.06.2020", entries: [ + "Huge belt performance improvements - up to 50% increase", "Preparations for the wires update", "Update belt placement performance on huge factories (by Phlosioneer)", "Allow clicking on variants to select them", diff --git a/src/js/core/config.js b/src/js/core/config.js index 6825e762..0018b746 100644 --- a/src/js/core/config.js +++ b/src/js/core/config.js @@ -51,7 +51,7 @@ export const globalConfig = { // Belt speeds // NOTICE: Update webpack.production.config too! beltSpeedItemsPerSecond: 2, - itemSpacingOnBelts: 0.8, + itemSpacingOnBelts: 0.63, minerSpeedItemsPerSecond: 0, // COMPUTED undergroundBeltMaxTilesByTier: [5, 8], diff --git a/src/js/core/modal_dialog_forms.js b/src/js/core/modal_dialog_forms.js index 4d1c9f97..1ded9a8b 100644 --- a/src/js/core/modal_dialog_forms.js +++ b/src/js/core/modal_dialog_forms.js @@ -19,7 +19,7 @@ export class FormElement { abstract; } - focus(parent) {} + focus() {} isValid() { return true; diff --git a/src/js/core/polyfills.js b/src/js/core/polyfills.js index e5efca1d..145b4c82 100644 --- a/src/js/core/polyfills.js +++ b/src/js/core/polyfills.js @@ -72,6 +72,7 @@ function objectPolyfills() { } if (!Object.entries) { + // @ts-ignore Object.entries = function entries(O) { return reduce( keys(O), diff --git a/src/js/core/utils.js b/src/js/core/utils.js index 23368317..3d4e524c 100644 --- a/src/js/core/utils.js +++ b/src/js/core/utils.js @@ -405,8 +405,6 @@ export function findNiceValue(num) { return Math_round(niceValue * 100) / 100; } -window.fn = findNiceValue; - /** * Finds a nice integer value * @see findNiceValue diff --git a/src/js/game/belt_path.js b/src/js/game/belt_path.js new file mode 100644 index 00000000..71d268ff --- /dev/null +++ b/src/js/game/belt_path.js @@ -0,0 +1,1066 @@ +import { Math_min, Math_max } from "../core/builtins"; +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { createLogger } from "../core/logging"; +import { epsilonCompare, round4Digits } from "../core/utils"; +import { Vector } from "../core/vector"; +import { BaseItem } from "./base_item"; +import { Entity } from "./entity"; +import { GameRoot } from "./root"; +import { Rectangle } from "../core/rectangle"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { gItemRegistry } from "../core/global_registries"; + +const logger = createLogger("belt_path"); + +// Helpers for more semantic access into interleaved arrays +const _nextDistance = 0; +const _item = 1; + +const DEBUG = G_IS_DEV && false; + +/** + * Stores a path of belts, used for optimizing performance + */ +export class BeltPath extends BasicSerializableObject { + static getId() { + return "BeltPath"; + } + + static getSchema() { + return { + entityPath: types.array(types.entity), + items: types.array(types.pair(types.ufloat, types.obj(gItemRegistry))), + spacingToFirstItem: types.ufloat, + }; + } + + /** + * Creates a path from a serialized object + * @param {GameRoot} root + * @param {Object} data + * @returns {BeltPath|string} + */ + static fromSerialized(root, data) { + // Create fake object which looks like a belt path but skips the constructor + const fakeObject = /** @type {BeltPath} */ (Object.create(BeltPath.prototype)); + fakeObject.root = root; + + // Deserialize the data + const errorCodeDeserialize = fakeObject.deserialize(data); + if (errorCodeDeserialize) { + return errorCodeDeserialize; + } + + // Compute other properties + fakeObject.init(false); + + return fakeObject; + } + + /** + * Initializes the path by computing the properties which are not saved + * @param {boolean} computeSpacing Whether to also compute the spacing + */ + init(computeSpacing = true) { + // Find acceptor and ejector + this.ejectorComp = this.entityPath[this.entityPath.length - 1].components.ItemEjector; + this.ejectorSlot = this.ejectorComp.slots[0]; + this.initialBeltComponent = this.entityPath[0].components.Belt; + + this.totalLength = this.computeTotalLength(); + + if (computeSpacing) { + this.spacingToFirstItem = this.totalLength; + } + + /** + * Current bounds of this path + * @type {Rectangle} + */ + this.worldBounds = this.computeBounds(); + + // Connect the belts + for (let i = 0; i < this.entityPath.length; ++i) { + this.entityPath[i].components.Belt.assignedPath = this; + } + } + + /** + * @param {GameRoot} root + * @param {Array} entityPath + */ + constructor(root, entityPath) { + super(); + this.root = root; + + assert(entityPath.length > 0, "invalid entity path"); + this.entityPath = entityPath; + + /** + * Stores the items sorted, and their distance to the previous item (or start) + * Layout: [distanceToNext, item] + * @type {Array<[number, BaseItem]>} + */ + this.items = []; + + /** + * Stores the spacing to the first item + */ + + this.init(); + + this.debug_checkIntegrity("constructor"); + } + + /** + * Returns whether this path can accept a new item + * @returns {boolean} + */ + canAcceptItem() { + return this.spacingToFirstItem >= globalConfig.itemSpacingOnBelts; + } + + /** + * Tries to accept the item + * @param {BaseItem} item + */ + tryAcceptItem(item) { + if (this.spacingToFirstItem >= globalConfig.itemSpacingOnBelts) { + this.items.unshift([this.spacingToFirstItem, item]); + this.spacingToFirstItem = 0; + return true; + } + return false; + } + + /** + * SLOW / Tries to find the item closest to the given tile + * @param {Vector} tile + * @returns {BaseItem|null} + */ + findItemAtTile(tile) { + // TODO: This breaks color blind mode otherwise + return null; + } + + /** + * Computes the tile bounds of the path + * @returns {Rectangle} + */ + computeBounds() { + let bounds = this.entityPath[0].components.StaticMapEntity.getTileSpaceBounds(); + for (let i = 1; i < this.entityPath.length; ++i) { + const staticComp = this.entityPath[i].components.StaticMapEntity; + const otherBounds = staticComp.getTileSpaceBounds(); + bounds = bounds.getUnion(otherBounds); + } + return bounds.allScaled(globalConfig.tileSize); + } + + /** + * Helper to throw an error on mismatch + * @param {string} change + * @param {Array} reason + */ + debug_failIntegrity(change, ...reason) { + throw new Error("belt path invalid (" + change + "): " + reason.map(i => "" + i).join(" ")); + } + + /** + * Checks if this path is valid + */ + debug_checkIntegrity(currentChange = "change") { + if (!G_IS_DEV) { + return; + } + + const fail = (...args) => this.debug_failIntegrity(currentChange, ...args); + + // Check for empty path + if (this.entityPath.length === 0) { + return fail("Belt path is empty"); + } + + // Check for mismatching length + const totalLength = this.computeTotalLength(); + if (!epsilonCompare(this.totalLength, totalLength, 0.01)) { + return this.debug_failIntegrity( + currentChange, + "Total length mismatch, stored =", + this.totalLength, + "but correct is", + totalLength + ); + } + + // Check for misconnected entities + for (let i = 0; i < this.entityPath.length - 1; ++i) { + const entity = this.entityPath[i]; + if (entity.destroyed) { + return fail("Reference to destroyed entity " + entity.uid); + } + + const followUp = this.root.systemMgr.systems.belt.findFollowUpEntity(entity); + if (!followUp) { + return fail( + "Follow up entity for the", + i, + "-th entity (total length", + this.entityPath.length, + ") was null!" + ); + } + if (followUp !== this.entityPath[i + 1]) { + return fail( + "Follow up entity mismatch, stored is", + this.entityPath[i + 1].uid, + "but real one is", + followUp.uid + ); + } + if (entity.components.Belt.assignedPath !== this) { + return fail( + "Entity with uid", + entity.uid, + "doesn't have this path assigned, but this path contains the entity." + ); + } + } + + // Check for right ejector component and slot + if (this.ejectorComp !== this.entityPath[this.entityPath.length - 1].components.ItemEjector) { + return fail("Stale ejectorComp handle"); + } + if (this.ejectorSlot !== this.ejectorComp.slots[0]) { + return fail("Stale ejector slot handle"); + } + if (!this.ejectorComp) { + return fail("Ejector comp not set"); + } + if (!this.ejectorSlot) { + return fail("Ejector slot not set"); + } + if (this.initialBeltComponent !== this.entityPath[0].components.Belt) { + return fail("Stale initial belt component handle"); + } + + // Check spacing + if (this.spacingToFirstItem > this.totalLength + 0.005) { + return fail( + currentChange, + "spacing to first item (", + this.spacingToFirstItem, + ") is greater than total length (", + this.totalLength, + ")" + ); + } + + // Check distance if empty + if (this.items.length === 0 && !epsilonCompare(this.spacingToFirstItem, this.totalLength, 0.01)) { + return fail( + currentChange, + "Path is empty but spacing to first item (", + this.spacingToFirstItem, + ") does not equal total length (", + this.totalLength, + ")" + ); + } + + // Check items etc + let currentPos = this.spacingToFirstItem; + for (let i = 0; i < this.items.length; ++i) { + const item = this.items[i]; + + if (item[_nextDistance] < 0 || item[_nextDistance] > this.totalLength + 0.02) { + return fail( + "Item has invalid offset to next item: ", + item[_nextDistance], + "(total length:", + this.totalLength, + ")" + ); + } + + currentPos += item[_nextDistance]; + } + + // Check the total sum matches + if (!epsilonCompare(currentPos, this.totalLength, 0.01)) { + return fail( + "total sum (", + currentPos, + ") of first item spacing (", + this.spacingToFirstItem, + ") and items does not match total length (", + this.totalLength, + ") -> items: " + this.items.map(i => i[_nextDistance]).join("|") + ); + } + + // Check bounds + const actualBounds = this.computeBounds(); + if (!actualBounds.equalsEpsilon(this.worldBounds, 0.01)) { + return fail("Bounds are stale"); + } + } + + /** + * Extends the belt path by the given belt + * @param {Entity} entity + */ + extendOnEnd(entity) { + DEBUG && logger.log("Extending belt path by entity at", entity.components.StaticMapEntity.origin); + + const beltComp = entity.components.Belt; + + // If the last belt has something on its ejector, put that into the path first + const pendingItem = this.ejectorComp.takeSlotItem(0); + if (pendingItem) { + // Ok, so we have a pending item + DEBUG && logger.log("Taking pending item and putting it back on the path"); + this.items.push([0, pendingItem]); + } + + // Append the entity + this.entityPath.push(entity); + + // Extend the path length + const additionalLength = beltComp.getEffectiveLengthTiles(); + this.totalLength += additionalLength; + DEBUG && logger.log(" Extended total length by", additionalLength, "to", this.totalLength); + + // If we have no item, just update the distance to the first item + if (this.items.length === 0) { + this.spacingToFirstItem = this.totalLength; + DEBUG && logger.log(" Extended spacing to first to", this.totalLength, "(= total length)"); + } else { + // Otherwise, update the next-distance of the last item + const lastItem = this.items[this.items.length - 1]; + DEBUG && + logger.log( + " Extended spacing of last item from", + lastItem[_nextDistance], + "to", + lastItem[_nextDistance] + additionalLength + ); + lastItem[_nextDistance] += additionalLength; + } + + // Update handles + this.ejectorComp = entity.components.ItemEjector; + this.ejectorSlot = this.ejectorComp.slots[0]; + + // Assign reference + beltComp.assignedPath = this; + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("extend-on-end"); + } + + /** + * Extends the path with the given entity on the beginning + * @param {Entity} entity + */ + extendOnBeginning(entity) { + const beltComp = entity.components.Belt; + + DEBUG && logger.log("Extending the path on the beginning"); + + // All items on that belt are simply lost (for now) + + const length = beltComp.getEffectiveLengthTiles(); + + // Extend the length of this path + this.totalLength += length; + + // Simply adjust the first item spacing cuz we have no items contained + this.spacingToFirstItem += length; + + // Set handles and append entity + beltComp.assignedPath = this; + this.entityPath.unshift(entity); + this.initialBeltComponent = this.entityPath[0].components.Belt; + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("extend-on-begin"); + } + + /** + * Returns if the given entity is the end entity of the path + * @param {Entity} entity + * @returns {boolean} + */ + isEndEntity(entity) { + return this.entityPath[this.entityPath.length - 1] === entity; + } + + /** + * Returns if the given entity is the start entity of the path + * @param {Entity} entity + * @returns {boolean} + */ + isStartEntity(entity) { + return this.entityPath[0] === entity; + } + + /** + * Splits this path at the given entity by removing it, and + * returning the new secondary paht + * @param {Entity} entity + * @returns {BeltPath} + */ + deleteEntityOnPathSplitIntoTwo(entity) { + DEBUG && logger.log("Splitting path at entity", entity.components.StaticMapEntity.origin); + + // First, find where the current path ends + const beltComp = entity.components.Belt; + 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"); + + let firstPathEntityCount = 0; + let firstPathLength = 0; + let firstPathEndEntity = null; + + for (let i = 0; i < this.entityPath.length; ++i) { + const otherEntity = this.entityPath[i]; + if (otherEntity === entity) { + DEBUG && logger.log("Found entity at", i, "of length", firstPathLength); + break; + } + + ++firstPathEntityCount; + firstPathEndEntity = otherEntity; + firstPathLength += otherEntity.components.Belt.getEffectiveLengthTiles(); + } + + DEBUG && + logger.log( + "First path ends at", + firstPathLength, + "and entity", + firstPathEndEntity.components.StaticMapEntity.origin, + "and has", + firstPathEntityCount, + "entities" + ); + + // Compute length of second path + const secondPathLength = this.totalLength - firstPathLength - entityLength; + const secondPathStart = firstPathLength + entityLength; + const secondEntities = this.entityPath.splice(firstPathEntityCount + 1); + DEBUG && + logger.log( + "Second path starts at", + secondPathStart, + "and has a length of ", + secondPathLength, + "with", + secondEntities.length, + "entities" + ); + + // Remove the last item + this.entityPath.pop(); + + DEBUG && logger.log("Splitting", this.items.length, "items"); + DEBUG && + logger.log( + "Old items are", + this.items.map(i => i[_nextDistance]) + ); + + // Create second path + const secondPath = new BeltPath(this.root, secondEntities); + + // Remove all items which are no longer relevant and transfer them to the second path + let itemPos = this.spacingToFirstItem; + for (let i = 0; i < this.items.length; ++i) { + const item = this.items[i]; + const distanceToNext = item[_nextDistance]; + + DEBUG && logger.log(" Checking item at", itemPos, "with distance of", distanceToNext, "to next"); + + // Check if this item is past the first path + if (itemPos >= firstPathLength) { + // Remove it from the first path + this.items.splice(i, 1); + i -= 1; + DEBUG && + logger.log(" Removed item from first path since its no longer contained @", itemPos); + + // Check if its on the second path (otherwise its on the removed belt and simply lost) + if (itemPos >= secondPathStart) { + // Put item on second path + secondPath.items.push([distanceToNext, item[_item]]); + DEBUG && + logger.log( + " Put item to second path @", + itemPos, + "with distance to next =", + distanceToNext + ); + + // If it was the first item, adjust the distance to the first item + if (secondPath.items.length === 1) { + DEBUG && logger.log(" Sinc it was the first, set sapcing of first to", itemPos); + secondPath.spacingToFirstItem = itemPos - secondPathStart; + } + } else { + DEBUG && logger.log(" Item was on the removed belt, so its gone - forever!"); + } + } else { + // Seems this item is on the first path (so all good), so just make sure it doesn't + // have a nextDistance which is bigger than the total path length + const clampedDistanceToNext = Math_min(itemPos + distanceToNext, firstPathLength) - itemPos; + if (clampedDistanceToNext < distanceToNext) { + DEBUG && + logger.log( + "Correcting next distance (first path) from", + distanceToNext, + "to", + clampedDistanceToNext + ); + item[_nextDistance] = clampedDistanceToNext; + } + } + + // Advance items + itemPos += distanceToNext; + } + + DEBUG && + logger.log( + "New items are", + this.items.map(i => i[_nextDistance]) + ); + + DEBUG && + logger.log( + "And second path items are", + secondPath.items.map(i => i[_nextDistance]) + ); + + // Adjust our total length + this.totalLength = firstPathLength; + + // Make sure that if we are empty, we set our first distance properly + if (this.items.length === 0) { + this.spacingToFirstItem = this.totalLength; + } + + // Set new ejector and acceptor handles + this.ejectorComp = firstPathEndEntity.components.ItemEjector; + this.ejectorSlot = this.ejectorComp.slots[0]; + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("split-two-first"); + secondPath.debug_checkIntegrity("split-two-second"); + + return secondPath; + } + + /** + * Deletes the last entity + * @param {Entity} entity + */ + deleteEntityOnEnd(entity) { + assert( + this.entityPath[this.entityPath.length - 1] === entity, + "Not actually the last entity (instead " + this.entityPath.indexOf(entity) + ")" + ); + + // Ok, first remove the entity + const beltComp = entity.components.Belt; + const beltLength = beltComp.getEffectiveLengthTiles(); + + DEBUG && + logger.log( + "Deleting last entity on path with length", + this.entityPath.length, + "(reducing", + this.totalLength, + " by", + beltLength, + ")" + ); + this.totalLength -= beltLength; + this.entityPath.pop(); + + DEBUG && + logger.log( + " New path has length of", + this.totalLength, + "with", + this.entityPath.length, + "entities" + ); + + // This is just for sanity + beltComp.assignedPath = null; + + // Clean up items + if (this.items.length === 0) { + // Simple case with no items, just update the first item spacing + this.spacingToFirstItem = this.totalLength; + } else { + // Ok, make sure we simply drop all items which are no longer contained + let itemOffset = this.spacingToFirstItem; + let lastItemOffset = itemOffset; + + DEBUG && logger.log(" Adjusting", this.items.length, "items"); + + for (let i = 0; i < this.items.length; ++i) { + const item = this.items[i]; + + // Get rid of items past this path + if (itemOffset >= this.totalLength) { + DEBUG && logger.log("Dropping item (current index=", i, ")"); + this.items.splice(i, 1); + i -= 1; + continue; + } + + DEBUG && logger.log("Item", i, "is at", itemOffset, "with next offset", item[_nextDistance]); + lastItemOffset = itemOffset; + itemOffset += item[_nextDistance]; + } + + // If we still have an item, make sure the last item matches + if (this.items.length > 0) { + // We can easily compute the next distance since we know where the last item is now + const lastDistance = this.totalLength - lastItemOffset; + assert( + lastDistance >= 0.0, + "Last item distance mismatch: " + + lastDistance + + " -> Total length was " + + this.totalLength + + " and lastItemOffset was " + + lastItemOffset + ); + + DEBUG && + logger.log( + "Adjusted distance of last item: it is at", + lastItemOffset, + "so it has a distance of", + lastDistance, + "to the end (", + this.totalLength, + ")" + ); + this.items[this.items.length - 1][_nextDistance] = lastDistance; + } else { + DEBUG && logger.log(" Removed all items so we'll update spacing to total length"); + + // We removed all items so update our spacing + this.spacingToFirstItem = this.totalLength; + } + } + + // Update handles + this.ejectorComp = this.entityPath[this.entityPath.length - 1].components.ItemEjector; + this.ejectorSlot = this.ejectorComp.slots[0]; + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("delete-on-end"); + } + + /** + * Deletes the entity of the start of the path + * @see deleteEntityOnEnd + * @param {Entity} entity + */ + deleteEntityOnStart(entity) { + assert( + entity === this.entityPath[0], + "Not actually the start entity (instead " + this.entityPath.indexOf(entity) + ")" + ); + + // Ok, first remove the entity + const beltComp = entity.components.Belt; + const beltLength = beltComp.getEffectiveLengthTiles(); + + DEBUG && + logger.log( + "Deleting first entity on path with length", + this.entityPath.length, + "(reducing", + this.totalLength, + " by", + beltLength, + ")" + ); + this.totalLength -= beltLength; + this.entityPath.shift(); + + DEBUG && + logger.log( + " New path has length of", + this.totalLength, + "with", + this.entityPath.length, + "entities" + ); + + // This is just for sanity + beltComp.assignedPath = null; + + // Clean up items + if (this.items.length === 0) { + // Simple case with no items, just update the first item spacing + this.spacingToFirstItem = this.totalLength; + } else { + // Simple case, we had no item on the beginning -> all good + if (this.spacingToFirstItem >= beltLength) { + DEBUG && + logger.log( + " No item on the first place, so we can just adjust the spacing (spacing=", + this.spacingToFirstItem, + ") removed =", + beltLength + ); + this.spacingToFirstItem -= beltLength; + } else { + // Welp, okay we need to drop all items which are < beltLength and adjust + // the other item offsets as well + + DEBUG && + logger.log( + " We have at least one item in the beginning, drop those and adjust spacing (first item @", + this.spacingToFirstItem, + ") since we removed", + beltLength, + "length from path" + ); + DEBUG && + logger.log( + " Items:", + this.items.map(i => i[_nextDistance]) + ); + + // Find offset to first item + let itemOffset = this.spacingToFirstItem; + for (let i = 0; i < this.items.length; ++i) { + const item = this.items[i]; + if (itemOffset <= beltLength) { + DEBUG && + logger.log( + " -> Dropping item with index", + i, + "at", + itemOffset, + "since it was on the removed belt" + ); + // This item must be dropped + this.items.splice(i, 1); + i -= 1; + itemOffset += item[_nextDistance]; + continue; + } else { + // This item can be kept, thus its the first we know + break; + } + } + + if (this.items.length > 0) { + DEBUG && + logger.log( + " Offset of first non-dropped item was at:", + itemOffset, + "-> setting spacing to it (total length=", + this.totalLength, + ")" + ); + + this.spacingToFirstItem = itemOffset - beltLength; + assert( + this.spacingToFirstItem >= 0.0, + "Invalid spacing after delete on start: " + this.spacingToFirstItem + ); + } else { + DEBUG && logger.log(" We dropped all items, simply set spacing to total length"); + // We dropped all items, simple one + this.spacingToFirstItem = this.totalLength; + } + } + } + + // Update handles + this.initialBeltComponent = this.entityPath[0].components.Belt; + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("delete-on-start"); + } + + /** + * Extends the path by the given other path + * @param {BeltPath} otherPath + */ + extendByPath(otherPath) { + assert(otherPath !== this, "Circular path dependency"); + + const entities = otherPath.entityPath; + DEBUG && logger.log("Extending path by other path, starting to add entities"); + + const oldLength = this.totalLength; + + DEBUG && logger.log(" Adding", entities.length, "new entities, current length =", this.totalLength); + + // First, append entities + for (let i = 0; i < entities.length; ++i) { + const entity = entities[i]; + const beltComp = entity.components.Belt; + + // Add to path and update references + this.entityPath.push(entity); + beltComp.assignedPath = this; + + // Update our length + const additionalLength = beltComp.getEffectiveLengthTiles(); + this.totalLength += additionalLength; + } + + DEBUG && + logger.log( + " Path is now", + this.entityPath.length, + "entities and has a length of", + this.totalLength + ); + + // Update handles + this.ejectorComp = this.entityPath[this.entityPath.length - 1].components.ItemEjector; + this.ejectorSlot = this.ejectorComp.slots[0]; + + // Now, update the distance of our last item + if (this.items.length !== 0) { + const lastItem = this.items[this.items.length - 1]; + lastItem[_nextDistance] += otherPath.spacingToFirstItem; + DEBUG && + logger.log(" Add distance to last item, effectively being", lastItem[_nextDistance], "now"); + } else { + // Seems we have no items, update our first item distance + this.spacingToFirstItem = oldLength + otherPath.spacingToFirstItem; + DEBUG && + logger.log( + " We had no items, so our new spacing to first is old length (", + oldLength, + ") plus others spacing to first (", + otherPath.spacingToFirstItem, + ") =", + this.spacingToFirstItem + ); + } + + DEBUG && logger.log(" Pushing", otherPath.items.length, "items from other path"); + + // Aaand push the other paths items + for (let i = 0; i < otherPath.items.length; ++i) { + const item = otherPath.items[i]; + this.items.push([item[_nextDistance], item[_item]]); + } + + // Update bounds + this.worldBounds = this.computeBounds(); + + this.debug_checkIntegrity("extend-by-path"); + } + + /** + * Computes the total length of the path + * @returns {number} + */ + computeTotalLength() { + let length = 0; + for (let i = 0; i < this.entityPath.length; ++i) { + length += this.entityPath[i].components.Belt.getEffectiveLengthTiles(); + } + return length; + } + + /** + * Performs one tick + */ + update() { + this.debug_checkIntegrity("pre-update"); + + // Divide by item spacing on belts since we use throughput and not speed + let beltSpeed = + this.root.hubGoals.getBeltBaseSpeed() * + this.root.dynamicTickrate.deltaSeconds * + globalConfig.itemSpacingOnBelts; + + if (G_IS_DEV && globalConfig.debug.instantBelts) { + beltSpeed *= 100; + } + + let minimumDistance = this.ejectorSlot.item ? globalConfig.itemSpacingOnBelts : 0; + + // Try to reduce spacing + let remainingAmount = beltSpeed; + for (let i = this.items.length - 1; i >= 0; --i) { + const nextDistanceAndItem = this.items[i]; + const minimumSpacing = minimumDistance; + + const takeAway = Math_max( + 0, + Math_min(remainingAmount, nextDistanceAndItem[_nextDistance] - minimumSpacing) + ); + + remainingAmount -= takeAway; + nextDistanceAndItem[_nextDistance] -= takeAway; + + this.spacingToFirstItem += takeAway; + if (remainingAmount < 0.01) { + break; + } + + minimumDistance = globalConfig.itemSpacingOnBelts; + } + + const lastItem = this.items[this.items.length - 1]; + if (lastItem && lastItem[_nextDistance] === 0) { + // Take over + if (this.ejectorComp.tryEject(0, lastItem[_item])) { + this.items.pop(); + } + } + + this.debug_checkIntegrity("post-update"); + } + + /** + * Computes a world space position from the given progress + * @param {number} progress + * @returns {Vector} + */ + computePositionFromProgress(progress) { + let currentLength = 0; + + // floating point issuses .. + assert(progress <= this.totalLength + 0.02, "Progress too big: " + progress); + + for (let i = 0; i < this.entityPath.length; ++i) { + const beltComp = this.entityPath[i].components.Belt; + const localLength = beltComp.getEffectiveLengthTiles(); + + if (currentLength + localLength >= progress || i === this.entityPath.length - 1) { + // Min required here due to floating point issues + const localProgress = Math_min(1.0, progress - currentLength); + + assert(localProgress >= 0.0, "Invalid local progress: " + localProgress); + const localSpace = beltComp.transformBeltToLocalSpace(localProgress); + return this.entityPath[i].components.StaticMapEntity.localTileToWorld(localSpace); + } + currentLength += localLength; + } + + assert(false, "invalid progress: " + progress + " (max: " + this.totalLength + ")"); + } + + /** + * + * @param {DrawParameters} parameters + */ + drawDebug(parameters) { + if (!parameters.visibleRect.containsRect(this.worldBounds)) { + return; + } + + parameters.context.fillStyle = "#d79a25"; + parameters.context.strokeStyle = "#d79a25"; + parameters.context.beginPath(); + + for (let i = 0; i < this.entityPath.length; ++i) { + const entity = this.entityPath[i]; + const pos = entity.components.StaticMapEntity; + const worldPos = pos.origin.toWorldSpaceCenterOfTile(); + + if (i === 0) { + parameters.context.moveTo(worldPos.x, worldPos.y); + } else { + parameters.context.lineTo(worldPos.x, worldPos.y); + } + } + parameters.context.stroke(); + + // Items + let progress = this.spacingToFirstItem; + for (let i = 0; i < this.items.length; ++i) { + const nextDistanceAndItem = this.items[i]; + const worldPos = this.computePositionFromProgress(progress).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "#268e4d"; + parameters.context.beginRoundedRect(worldPos.x - 5, worldPos.y - 5, 10, 10, 3); + parameters.context.fill(); + parameters.context.font = "6px GameFont"; + parameters.context.fillStyle = "#111"; + parameters.context.fillText( + "" + round4Digits(nextDistanceAndItem[_nextDistance]), + worldPos.x + 5, + worldPos.y + 2 + ); + progress += nextDistanceAndItem[_nextDistance]; + nextDistanceAndItem[_item].draw(worldPos.x, worldPos.y, parameters, 10); + } + + for (let i = 0; i < this.entityPath.length; ++i) { + const entity = this.entityPath[i]; + parameters.context.fillStyle = "#d79a25"; + const pos = entity.components.StaticMapEntity; + const worldPos = pos.origin.toWorldSpaceCenterOfTile(); + parameters.context.beginCircle(worldPos.x, worldPos.y, i === 0 ? 5 : 3); + parameters.context.fill(); + } + + for (let progress = 0; progress <= this.totalLength + 0.01; progress += 0.2) { + const worldPos = this.computePositionFromProgress(progress).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "red"; + parameters.context.beginCircle(worldPos.x, worldPos.y, 1); + parameters.context.fill(); + } + + const firstItemIndicator = this.computePositionFromProgress( + this.spacingToFirstItem + ).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "purple"; + parameters.context.fillRect(firstItemIndicator.x - 3, firstItemIndicator.y - 1, 6, 2); + } + + /** + * Draws the path + * @param {DrawParameters} parameters + */ + draw(parameters) { + if (!parameters.visibleRect.containsRect(this.worldBounds)) { + return; + } + + let progress = this.spacingToFirstItem; + for (let i = 0; i < this.items.length; ++i) { + const nextDistanceAndItem = this.items[i]; + const worldPos = this.computePositionFromProgress(progress).toWorldSpaceCenterOfTile(); + if (parameters.visibleRect.containsCircle(worldPos.x, worldPos.y, 10)) { + nextDistanceAndItem[_item].draw(worldPos.x, worldPos.y, parameters); + } + progress += nextDistanceAndItem[_nextDistance]; + } + } +} diff --git a/src/js/game/hud/parts/blueprint.js b/src/js/game/blueprint.js similarity index 85% rename from src/js/game/hud/parts/blueprint.js rename to src/js/game/blueprint.js index c53163d9..d923b2e1 100644 --- a/src/js/game/hud/parts/blueprint.js +++ b/src/js/game/blueprint.js @@ -1,13 +1,13 @@ -import { DrawParameters } from "../../../core/draw_parameters"; -import { Loader } from "../../../core/loader"; -import { createLogger } from "../../../core/logging"; -import { Vector } from "../../../core/vector"; -import { Entity } from "../../entity"; -import { GameRoot } from "../../root"; -import { findNiceIntegerValue } from "../../../core/utils"; -import { Math_pow } from "../../../core/builtins"; -import { blueprintShape } from "../../upgrades"; -import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { Loader } from "../core/loader"; +import { createLogger } from "../core/logging"; +import { Vector } from "../core/vector"; +import { Entity } from "./entity"; +import { GameRoot } from "./root"; +import { findNiceIntegerValue } from "../core/utils"; +import { Math_pow } from "../core/builtins"; +import { blueprintShape } from "./upgrades"; +import { globalConfig } from "../core/config"; const logger = createLogger("blueprint"); @@ -176,7 +176,6 @@ export class Blueprint { tryPlace(root, tile) { return root.logic.performBulkOperation(() => { let anyPlaced = false; - const beltsToRegisterLater = []; for (let i = 0; i < this.entities.length; ++i) { let placeable = true; const entity = this.entities[i]; @@ -217,21 +216,10 @@ export class Blueprint { root.map.placeStaticEntity(clone); - // Registering a belt immediately triggers a recalculation of surrounding belt - // directions, which is no good when not all belts have been placed. To resolve - // this, only register belts after all entities have been placed. - if (!clone.components.Belt) { - root.entityMgr.registerEntity(clone); - } else { - beltsToRegisterLater.push(clone); - } + root.entityMgr.registerEntity(clone); anyPlaced = true; } } - - for (let i = 0; i < beltsToRegisterLater.length; i++) { - root.entityMgr.registerEntity(beltsToRegisterLater[i]); - } return anyPlaced; }); } diff --git a/src/js/game/components/belt.js b/src/js/game/components/belt.js index a9be5c99..75ba27d5 100644 --- a/src/js/game/components/belt.js +++ b/src/js/game/components/belt.js @@ -1,12 +1,12 @@ -import { Component } from "../component"; +import { Math_cos, Math_PI, Math_sin } from "../../core/builtins"; +import { enumDirection, Vector } from "../../core/vector"; import { types } from "../../savegame/serialization"; -import { gItemRegistry } from "../../core/global_registries"; -import { BaseItem } from "../base_item"; -import { Vector, enumDirection } from "../../core/vector"; -import { Math_PI, Math_sin, Math_cos } from "../../core/builtins"; -import { globalConfig } from "../../core/config"; +import { BeltPath } from "../belt_path"; +import { Component } from "../component"; import { Entity } from "../entity"; +export const curvedBeltLength = /* Math_PI / 4 */ 0.78; + export class BeltComponent extends Component { static getId() { return "Belt"; @@ -16,7 +16,6 @@ export class BeltComponent extends Component { // The followUpCache field is not serialized. return { direction: types.string, - sortedItems: types.array(types.pair(types.float, types.obj(gItemRegistry))), }; } @@ -34,11 +33,22 @@ export class BeltComponent extends Component { this.direction = direction; - /** @type {Array<[number, BaseItem]>} */ - this.sortedItems = []; - /** @type {Entity} */ this.followUpCache = null; + + /** + * The path this belt is contained in, not serialized + * @type {BeltPath} + */ + this.assignedPath = null; + } + + /** + * Returns the effective length of this belt in tile space + * @returns {number} + */ + getEffectiveLengthTiles() { + return this.direction === enumDirection.top ? 1.0 : curvedBeltLength; } /** @@ -50,14 +60,17 @@ export class BeltComponent extends Component { transformBeltToLocalSpace(progress) { switch (this.direction) { case enumDirection.top: + assert(progress <= 1.02, "Invalid progress: " + progress); return new Vector(0, 0.5 - progress); case enumDirection.right: { - const arcProgress = progress * 0.5 * Math_PI; + assert(progress <= curvedBeltLength + 0.02, "Invalid progress 2: " + progress); + const arcProgress = (progress / curvedBeltLength) * 0.5 * Math_PI; return new Vector(0.5 - 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress)); } case enumDirection.left: { - const arcProgress = progress * 0.5 * Math_PI; + assert(progress <= curvedBeltLength + 0.02, "Invalid progress 3: " + progress); + const arcProgress = (progress / curvedBeltLength) * 0.5 * Math_PI; return new Vector(-0.5 + 0.5 * Math_cos(arcProgress), 0.5 - 0.5 * Math_sin(arcProgress)); } default: @@ -65,46 +78,4 @@ export class BeltComponent extends Component { return new Vector(0, 0); } } - - /** - * Returns if the belt can currently accept an item from the given direction - */ - canAcceptItem() { - const firstItem = this.sortedItems[0]; - if (!firstItem) { - return true; - } - - return firstItem[0] > globalConfig.itemSpacingOnBelts; - } - - /** - * Pushes a new item to the belt - * @param {BaseItem} item - */ - takeItem(item, leftoverProgress = 0.0) { - if (G_IS_DEV) { - assert( - this.sortedItems.length === 0 || - leftoverProgress <= this.sortedItems[0][0] - globalConfig.itemSpacingOnBelts + 0.001, - "Invalid leftover: " + - leftoverProgress + - " items are " + - this.sortedItems.map(item => item[0]) - ); - assert(leftoverProgress < 1.0, "Invalid leftover: " + leftoverProgress); - } - this.sortedItems.unshift([leftoverProgress, item]); - } - - /** - * Returns how much space there is to the first item - */ - getDistanceToFirstItemCenter() { - const firstItem = this.sortedItems[0]; - if (!firstItem) { - return 1; - } - return firstItem[0]; - } } diff --git a/src/js/game/components/item_ejector.js b/src/js/game/components/item_ejector.js index e78faa36..7ae97724 100644 --- a/src/js/game/components/item_ejector.js +++ b/src/js/game/components/item_ejector.js @@ -194,4 +194,17 @@ export class ItemEjectorComponent extends Component { this.slots[slotIndex].progress = this.instantEject ? 1 : 0; return true; } + + /** + * Clears the given slot and returns the item it had + * @param {number} slotIndex + * @returns {BaseItem|null} + */ + takeSlotItem(slotIndex) { + const slot = this.slots[slotIndex]; + const item = slot.item; + slot.item = null; + slot.progress = 0.0; + return item; + } } diff --git a/src/js/game/core.js b/src/js/game/core.js index 203e005b..e16a57a8 100644 --- a/src/js/game/core.js +++ b/src/js/game/core.js @@ -418,6 +418,10 @@ export class GameCore { root.map.drawStaticEntityDebugOverlays(params); } + if (G_IS_DEV && globalConfig.debug.renderBeltPaths) { + systems.belt.drawBeltPathDebug(params); + } + // END OF GAME CONTENT // ----- diff --git a/src/js/game/hud/hud.js b/src/js/game/hud/hud.js index f9c28d93..d7c2de51 100644 --- a/src/js/game/hud/hud.js +++ b/src/js/game/hud/hud.js @@ -74,8 +74,11 @@ export class GameHUD { shapeViewer: new HUDShapeViewer(this.root), wiresOverlay: new HUDWiresOverlay(this.root), + // Typing hints + /* typehints:start */ /** @type {HUDChangesDebugger} */ changesDebugger: null, + /* typehints:end */ }; this.signals = { diff --git a/src/js/game/hud/parts/blueprint_placer.js b/src/js/game/hud/parts/blueprint_placer.js index c98fbf2d..6b2af42e 100644 --- a/src/js/game/hud/parts/blueprint_placer.js +++ b/src/js/game/hud/parts/blueprint_placer.js @@ -9,7 +9,7 @@ import { KEYMAPPINGS } from "../../key_action_mapper"; import { blueprintShape } from "../../upgrades"; import { BaseHUDPart } from "../base_hud_part"; import { DynamicDomAttach } from "../dynamic_dom_attach"; -import { Blueprint } from "./blueprint"; +import { Blueprint } from "../../blueprint"; import { SOUNDS } from "../../../platform/sound"; export class HUDBlueprintPlacer extends BaseHUDPart { diff --git a/src/js/game/hud/parts/color_blind_helper.js b/src/js/game/hud/parts/color_blind_helper.js index 4e6a0229..7e79fa1e 100644 --- a/src/js/game/hud/parts/color_blind_helper.js +++ b/src/js/game/hud/parts/color_blind_helper.js @@ -48,9 +48,9 @@ export class HUDColorBlindHelper extends BaseHUDPart { // Check if the belt has a color item if (beltComp) { - const firstItem = beltComp.sortedItems[0]; - if (firstItem && firstItem[1] instanceof ColorItem) { - return firstItem[1].color; + const item = beltComp.assignedPath.findItemAtTile(tile); + if (item && item instanceof ColorItem) { + return item.color; } } diff --git a/src/js/game/hud/parts/debug_changes.js b/src/js/game/hud/parts/debug_changes.js index 46ad5234..1502afa2 100644 --- a/src/js/game/hud/parts/debug_changes.js +++ b/src/js/game/hud/parts/debug_changes.js @@ -27,7 +27,7 @@ export class HUDChangesDebugger extends BaseHUDPart { * @param {string} fillColor Color to display (Hex) * @param {number=} timeToDisplay How long to display the change */ - renderChange(label, area, fillColor, timeToDisplay = 2) { + renderChange(label, area, fillColor, timeToDisplay = 0.3) { this.changes.push({ label, area: area.clone(), diff --git a/src/js/game/logic.js b/src/js/game/logic.js index 5c1bbf66..79caf38b 100644 --- a/src/js/game/logic.js +++ b/src/js/game/logic.js @@ -196,7 +196,7 @@ export class GameLogic { * @param {function} operation */ performBulkOperation(operation) { - logger.log("Running bulk operation ..."); + logger.warn("Running bulk operation ..."); assert(!this.root.bulkOperationRunning, "Can not run two bulk operations twice"); this.root.bulkOperationRunning = true; const now = performanceNow(); @@ -227,6 +227,7 @@ export class GameLogic { } this.root.map.removeStaticEntity(building); this.root.entityMgr.destroyEntity(building); + this.root.entityMgr.processDestroyList(); return true; } diff --git a/src/js/game/systems/belt.js b/src/js/game/systems/belt.js index 5e4ac7f0..638351f5 100644 --- a/src/js/game/systems/belt.js +++ b/src/js/game/systems/belt.js @@ -1,21 +1,18 @@ -import { Math_sqrt, Math_max } from "../../core/builtins"; +import { Math_sqrt } from "../../core/builtins"; import { globalConfig } from "../../core/config"; import { DrawParameters } from "../../core/draw_parameters"; -import { gMetaBuildingRegistry } from "../../core/global_registries"; import { Loader } from "../../core/loader"; import { createLogger } from "../../core/logging"; -import { Rectangle } from "../../core/rectangle"; import { AtlasSprite } from "../../core/sprites"; -import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../../core/vector"; -import { MetaBeltBaseBuilding } from "../buildings/belt_base"; +import { enumDirection, enumDirectionToVector, enumInvertedDirections } from "../../core/vector"; +import { BeltPath } from "../belt_path"; import { BeltComponent } from "../components/belt"; import { Entity } from "../entity"; import { GameSystemWithFilter } from "../game_system_with_filter"; import { MapChunkView } from "../map_chunk_view"; -import { defaultBuildingVariant } from "../meta_building"; +import { fastArrayDeleteValue } from "../../core/utils"; export const BELT_ANIM_COUNT = 28; -const SQRT_2 = Math_sqrt(2); const logger = createLogger("belt"); @@ -52,10 +49,53 @@ export class BeltSystem extends GameSystemWithFilter { this.root.signals.entityAdded.add(this.updateSurroundingBeltPlacement, this); this.root.signals.entityDestroyed.add(this.updateSurroundingBeltPlacement, this); - this.root.signals.postLoadHook.add(this.computeBeltCache, this); + this.root.signals.entityDestroyed.add(this.onEntityDestroyed, this); + this.root.signals.entityAdded.add(this.onEntityAdded, this); - /** @type {Rectangle} */ - this.areaToRecompute = null; + // /** @type {Rectangle} */ + // this.areaToRecompute = null; + + /** @type {Array} */ + this.beltPaths = []; + } + + /** + * Serializes all belt paths + */ + serializePaths() { + let data = []; + for (let i = 0; i < this.beltPaths.length; ++i) { + data.push(this.beltPaths[i].serialize()); + } + return data; + } + + /** + * Deserializes all belt paths + * @param {Array} data + */ + deserializePaths(data) { + if (!Array.isArray(data)) { + return "Belt paths are not an array: " + typeof data; + } + + for (let i = 0; i < data.length; ++i) { + const path = BeltPath.fromSerialized(this.root, data[i]); + if (!(path instanceof BeltPath)) { + return "Failed to create path from belt data: " + path; + } + + this.beltPaths.push(path); + } + + if (this.beltPaths.length === 0) { + logger.warn("Recomputing belt paths (most likely the savegame is old)"); + this.recomputeAllBeltPaths(); + } else { + logger.warn("Restored", this.beltPaths.length, "belt paths"); + } + + this.verifyBeltPaths(); } /** @@ -72,10 +112,7 @@ export class BeltSystem extends GameSystemWithFilter { return; } - if (entity.components.Belt) { - this.cacheNeedsUpdate = true; - } - + /* const metaBelt = gMetaBuildingRegistry.findByClass(MetaBeltBaseBuilding); // Compute affected area @@ -85,6 +122,8 @@ export class BeltSystem extends GameSystemWithFilter { // Store if anything got changed, if so we need to queue a recompute let anythingChanged = false; + anythingChanged = true; // TODO / FIXME + for (let x = affectedArea.x; x < affectedArea.right(); ++x) { for (let y = affectedArea.y; y < affectedArea.bottom(); ++y) { if (!originalRect.containsPoint(x, y)) { @@ -121,10 +160,133 @@ export class BeltSystem extends GameSystemWithFilter { logger.log("Queuing recompute:", this.areaToRecompute); } } + + // FIXME + this.areaToRecompute = new Rectangle(-1000, -1000, 2000, 2000); + */ + } + + /** + * Called when an entity got destroyed + * @param {Entity} entity + */ + onEntityDestroyed(entity) { + if (!this.root.gameInitialized) { + return; + } + + if (!entity.components.Belt) { + return; + } + + const assignedPath = entity.components.Belt.assignedPath; + assert(assignedPath, "Entity has no belt path assigned"); + this.deleteEntityFromPath(assignedPath, entity); + this.verifyBeltPaths(); + } + + /** + * Attempts to delete the belt from its current path + * @param {BeltPath} path + * @param {Entity} entity + */ + deleteEntityFromPath(path, entity) { + if (path.entityPath.length === 1) { + // This is a single entity path, easy to do, simply erase whole path + fastArrayDeleteValue(this.beltPaths, path); + return; + } + + // Notice: Since there might be circular references, it is important to check + // which role the entity has + if (path.isStartEntity(entity)) { + // We tried to delete the start + path.deleteEntityOnStart(entity); + } else if (path.isEndEntity(entity)) { + // We tried to delete the end + path.deleteEntityOnEnd(entity); + } else { + // We tried to delete something inbetween + const newPath = path.deleteEntityOnPathSplitIntoTwo(entity); + this.beltPaths.push(newPath); + } + } + + /** + * Called when an entity got added + * @param {Entity} entity + */ + onEntityAdded(entity) { + if (!this.root.gameInitialized) { + return; + } + + if (!entity.components.Belt) { + return; + } + + const fromEntity = this.findSupplyingEntity(entity); + const toEntity = this.findFollowUpEntity(entity); + + // Check if we can add the entity to the previous path + if (fromEntity) { + const fromPath = fromEntity.components.Belt.assignedPath; + fromPath.extendOnEnd(entity); + + // Check if we now can extend the current path by the next path + if (toEntity) { + const toPath = toEntity.components.Belt.assignedPath; + + if (fromPath === toPath) { + // This is a circular dependency -> Ignore + } else { + fromPath.extendByPath(toPath); + + // Delete now obsolete path + fastArrayDeleteValue(this.beltPaths, toPath); + } + } + } else { + if (toEntity) { + // Prepend it to the other path + const toPath = toEntity.components.Belt.assignedPath; + toPath.extendOnBeginning(entity); + } else { + // This is an empty belt path + const path = new BeltPath(this.root, [entity]); + this.beltPaths.push(path); + } + } + + this.verifyBeltPaths(); } draw(parameters) { - this.forEachMatchingEntityOnScreen(parameters, this.drawEntityItems.bind(this)); + for (let i = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].draw(parameters); + } + } + + /** + * Verifies all belt paths + */ + verifyBeltPaths() { + if (G_IS_DEV && true) { + for (let i = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].debug_checkIntegrity("general-verify"); + } + + const belts = this.root.entityMgr.getAllWithComponent(BeltComponent); + for (let i = 0; i < belts.length; ++i) { + const path = belts[i].components.Belt.assignedPath; + if (!path) { + throw new Error("Belt has no path: " + belts[i].uid); + } + if (this.beltPaths.indexOf(path) < 0) { + throw new Error("Path of entity not contained: " + belts[i].uid); + } + } + } } /** @@ -166,166 +328,102 @@ export class BeltSystem extends GameSystemWithFilter { } /** - * Recomputes the belt cache + * Finds the supplying belt for a given belt. Used for building the dependencies + * @param {Entity} entity */ - computeBeltCache() { - if (this.areaToRecompute) { - logger.log("Updating belt cache by updating area:", this.areaToRecompute); - - if (G_IS_DEV && globalConfig.debug.renderChanges) { - this.root.hud.parts.changesDebugger.renderChange( - "belt-area", - this.areaToRecompute, - "#00fff6" - ); - } + findSupplyingEntity(entity) { + const staticComp = entity.components.StaticMapEntity; - for (let x = this.areaToRecompute.x; x < this.areaToRecompute.right(); ++x) { - for (let y = this.areaToRecompute.y; y < this.areaToRecompute.bottom(); ++y) { - const tile = this.root.map.getTileContentXY(x, y); - if (tile && tile.components.Belt) { - tile.components.Belt.followUpCache = this.findFollowUpEntity(tile); - } - } - } + const supplyDirection = staticComp.localDirectionToWorld(enumDirection.bottom); + const supplyVector = enumDirectionToVector[supplyDirection]; - // Reset stale areas afterwards - this.areaToRecompute = null; - } else { - logger.log("Doing full belt recompute"); - - if (G_IS_DEV && globalConfig.debug.renderChanges) { - this.root.hud.parts.changesDebugger.renderChange( - "", - new Rectangle(-1000, -1000, 2000, 2000), - "#00fff6" - ); - } + const supplyTile = staticComp.origin.add(supplyVector); + const supplyEntity = this.root.map.getTileContent(supplyTile); - for (let i = 0; i < this.allEntities.length; ++i) { - const entity = this.allEntities[i]; - entity.components.Belt.followUpCache = this.findFollowUpEntity(entity); + // Check if theres a belt at the tile we point to + if (supplyEntity) { + const supplyBeltComp = supplyEntity.components.Belt; + if (supplyBeltComp) { + const supplyStatic = supplyEntity.components.StaticMapEntity; + const supplyEjector = supplyEntity.components.ItemEjector; + + // Check if the belt accepts items from our direction + const ejectorSlots = supplyEjector.slots; + for (let i = 0; i < ejectorSlots.length; ++i) { + const slot = ejectorSlots[i]; + const localDirection = supplyStatic.localDirectionToWorld(slot.direction); + if (enumInvertedDirections[localDirection] === supplyDirection) { + return supplyEntity; + } + } } } - } - update() { - if (this.areaToRecompute) { - this.computeBeltCache(); - } + return null; + } - // Divide by item spacing on belts since we use throughput and not speed - let beltSpeed = - this.root.hubGoals.getBeltBaseSpeed() * - this.root.dynamicTickrate.deltaSeconds * - globalConfig.itemSpacingOnBelts; + /** + * Computes the belt path network + */ + recomputeAllBeltPaths() { + logger.warn("Recomputing all belt paths"); + const visitedUids = new Set(); - if (G_IS_DEV && globalConfig.debug.instantBelts) { - beltSpeed *= 100; - } + const result = []; for (let i = 0; i < this.allEntities.length; ++i) { const entity = this.allEntities[i]; - - const beltComp = entity.components.Belt; - const items = beltComp.sortedItems; - - if (items.length === 0) { - // Fast out for performance + if (visitedUids.has(entity.uid)) { continue; } - const ejectorComp = entity.components.ItemEjector; - let maxProgress = 1; + // Mark entity as visited + visitedUids.add(entity.uid); - /* PERFORMANCE OPTIMIZATION */ - // Original: - // const isCurrentlyEjecting = ejectorComp.isAnySlotEjecting(); - // Replaced (Since belts always have just one slot): - const ejectorSlot = ejectorComp.slots[0]; - const isCurrentlyEjecting = ejectorSlot.item; + // Compute path, start with entity and find precedors / successors + const path = [entity]; - // When ejecting, we can not go further than the item spacing since it - // will be on the corner - if (isCurrentlyEjecting) { - maxProgress = 1 - globalConfig.itemSpacingOnBelts; - } else { - // Otherwise our progress depends on the follow up - if (beltComp.followUpCache) { - const spacingOnBelt = beltComp.followUpCache.components.Belt.getDistanceToFirstItemCenter(); - maxProgress = Math.min(2, 1 - globalConfig.itemSpacingOnBelts + spacingOnBelt); + let maxIter = 9999; - // Useful check, but hurts performance - // assert(maxProgress >= 0.0, "max progress < 0 (I) (" + maxProgress + ")"); + // Find precedors + let prevEntity = this.findSupplyingEntity(entity); + while (prevEntity && --maxIter > 0) { + if (visitedUids.has(prevEntity.uid)) { + break; } + path.unshift(prevEntity); + visitedUids.add(prevEntity.uid); + prevEntity = this.findSupplyingEntity(prevEntity); } - let speedMultiplier = 1; - if (beltComp.direction !== enumDirection.top) { - // Curved belts are shorter, thus being quicker (Looks weird otherwise) - speedMultiplier = SQRT_2; - } - - // How much offset we add when transferring to a new belt - // This substracts one tick because the belt will be updated directly - // afterwards anyways - const takeoverOffset = 1.0 + beltSpeed * speedMultiplier; + // Find succedors + let nextEntity = this.findFollowUpEntity(entity); + while (nextEntity && --maxIter > 0) { + if (visitedUids.has(nextEntity.uid)) { + break; + } - // Not really nice. haven't found the reason for this yet. - if (items.length > 2 / globalConfig.itemSpacingOnBelts) { - beltComp.sortedItems = []; + path.push(nextEntity); + visitedUids.add(nextEntity.uid); + nextEntity = this.findFollowUpEntity(nextEntity); } - for (let itemIndex = items.length - 1; itemIndex >= 0; --itemIndex) { - const progressAndItem = items[itemIndex]; + assert(maxIter > 1, "Ran out of iterations"); + result.push(new BeltPath(this.root, path)); + } - progressAndItem[0] = Math.min(maxProgress, progressAndItem[0] + speedMultiplier * beltSpeed); - assert(progressAndItem[0] >= 0, "Bad progress: " + progressAndItem[0]); + logger.log("Found", this.beltPaths.length, "belt paths"); + this.beltPaths = result; + } - if (progressAndItem[0] >= 1.0) { - if (beltComp.followUpCache) { - const followUpBelt = beltComp.followUpCache.components.Belt; - if (followUpBelt.canAcceptItem()) { - followUpBelt.takeItem( - progressAndItem[1], - Math_max(0, progressAndItem[0] - takeoverOffset) - ); - items.splice(itemIndex, 1); - } else { - // Well, we couldn't really take it to a follow up belt, keep it at - // max progress - progressAndItem[0] = 1.0; - maxProgress = 1 - globalConfig.itemSpacingOnBelts; - } - } else { - // Try to give this item to a new belt - - /* PERFORMANCE OPTIMIZATION */ - - // Original: - // const freeSlot = ejectorComp.getFirstFreeSlot(); - - // Replaced - if (ejectorSlot.item) { - // So, we don't have a free slot - damned! - progressAndItem[0] = 1.0; - maxProgress = 1 - globalConfig.itemSpacingOnBelts; - } else { - // We got a free slot, remove this item and keep it on the ejector slot - if (!ejectorComp.tryEject(0, progressAndItem[1])) { - assert(false, "Ejection failed"); - } - items.splice(itemIndex, 1); - - // NOTICE: Do not override max progress here at all, this leads to issues - } - } - } else { - // We just moved this item forward, so determine the maximum progress of other items - maxProgress = Math.max(0, progressAndItem[0] - globalConfig.itemSpacingOnBelts); - } - } + update() { + this.verifyBeltPaths(); + + for (let i = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].update(); } + + this.verifyBeltPaths(); } /** @@ -368,38 +466,12 @@ export class BeltSystem extends GameSystemWithFilter { } /** + * Draws the belt parameters * @param {DrawParameters} parameters - * @param {Entity} entity */ - drawEntityItems(parameters, entity) { - const beltComp = entity.components.Belt; - const staticComp = entity.components.StaticMapEntity; - - const items = beltComp.sortedItems; - - if (items.length === 0) { - // Fast out for performance - return; - } - - if (!staticComp.shouldBeDrawn(parameters)) { - return; - } - - for (let i = 0; i < items.length; ++i) { - const itemAndProgress = items[i]; - - // Nice would be const [pos, item] = itemAndPos; but that gets polyfilled and is super slow then - const progress = itemAndProgress[0]; - const item = itemAndProgress[1]; - - const position = staticComp.applyRotationToVector(beltComp.transformBeltToLocalSpace(progress)); - - item.draw( - (staticComp.origin.x + position.x + 0.5) * globalConfig.tileSize, - (staticComp.origin.y + position.y + 0.5) * globalConfig.tileSize, - parameters - ); + drawBeltPathDebug(parameters) { + for (let i = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].drawDebug(parameters); } } } diff --git a/src/js/game/systems/item_ejector.js b/src/js/game/systems/item_ejector.js index d6597ecd..b5da836a 100644 --- a/src/js/game/systems/item_ejector.js +++ b/src/js/game/systems/item_ejector.js @@ -239,9 +239,9 @@ export class ItemEjectorSystem extends GameSystemWithFilter { const beltComp = receiver.components.Belt; if (beltComp) { - // Ayy, its a belt! - if (beltComp.canAcceptItem()) { - beltComp.takeItem(item); + const path = beltComp.assignedPath; + assert(path, "belt has no path"); + if (path.tryAcceptItem(item)) { return true; } } diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index 359a48b5..d5395dce 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -14,6 +14,7 @@ import { SavegameInterface_V1001 } from "./schemas/1001"; import { SavegameInterface_V1002 } from "./schemas/1002"; import { SavegameInterface_V1003 } from "./schemas/1003"; import { SavegameInterface_V1004 } from "./schemas/1004"; +import { SavegameInterface_V1005 } from "./schemas/1005"; const logger = createLogger("savegame"); @@ -45,7 +46,7 @@ export class Savegame extends ReadWriteProxy { * @returns {number} */ static getCurrentVersion() { - return 1004; + return 1005; } /** @@ -104,6 +105,11 @@ export class Savegame extends ReadWriteProxy { data.version = 1004; } + if (data.version === 1004) { + SavegameInterface_V1005.migrate1004to1005(data); + data.version = 1005; + } + return ExplainedResult.good(); } diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js index 6144ca62..fb1df52f 100644 --- a/src/js/savegame/savegame_interface_registry.js +++ b/src/js/savegame/savegame_interface_registry.js @@ -5,6 +5,7 @@ import { SavegameInterface_V1001 } from "./schemas/1001"; import { SavegameInterface_V1002 } from "./schemas/1002"; import { SavegameInterface_V1003 } from "./schemas/1003"; import { SavegameInterface_V1004 } from "./schemas/1004"; +import { SavegameInterface_V1005 } from "./schemas/1005"; /** @type {Object.} */ export const savegameInterfaces = { @@ -13,6 +14,7 @@ export const savegameInterfaces = { 1002: SavegameInterface_V1002, 1003: SavegameInterface_V1003, 1004: SavegameInterface_V1004, + 1005: SavegameInterface_V1005, }; const logger = createLogger("savegame_interface_registry"); diff --git a/src/js/savegame/savegame_serializer.js b/src/js/savegame/savegame_serializer.js index 52a59528..59675668 100644 --- a/src/js/savegame/savegame_serializer.js +++ b/src/js/savegame/savegame_serializer.js @@ -40,6 +40,7 @@ export class SavegameSerializer { hubGoals: root.hubGoals.serialize(), pinnedShapes: root.hud.parts.pinnedShapes.serialize(), waypoints: root.hud.parts.waypoints.serialize(), + beltPaths: root.systemMgr.systems.belt.serializePaths(), }; data.entities = this.internal.serializeEntityArray(root.entityMgr.entities); @@ -140,6 +141,7 @@ export class SavegameSerializer { errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes); errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints); errorReason = errorReason || this.internal.deserializeEntityArray(root, savegame.entities); + errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths); // Check for errors if (errorReason) { diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index 6211150f..642865cd 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -5,6 +5,10 @@ import { Entity } from "../game/entity"; * }} SavegameStats */ +/** + * + */ + /** * @typedef {{ * camera: any, @@ -14,7 +18,8 @@ import { Entity } from "../game/entity"; * hubGoals: any, * pinnedShapes: any, * waypoints: any, - * entities: Array + * entities: Array, + * beltPaths: Array * }} SerializedGame */ diff --git a/src/js/savegame/schemas/1005.js b/src/js/savegame/schemas/1005.js new file mode 100644 index 00000000..f86a280d --- /dev/null +++ b/src/js/savegame/schemas/1005.js @@ -0,0 +1,29 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1004 } from "./1004.js"; + +const schema = require("./1005.json"); +const logger = createLogger("savegame_interface/1005"); + +export class SavegameInterface_V1005 extends SavegameInterface_V1004 { + getVersion() { + return 1005; + } + + getSchemaUncached() { + return schema; + } + + /** + * @param {import("../savegame_typedefs.js").SavegameData} data + */ + static migrate1004to1005(data) { + logger.log("Migrating 1004 to 1005"); + const dump = data.dump; + if (!dump) { + return true; + } + + // just reset belt paths for now + dump.beltPaths = []; + } +} diff --git a/src/js/savegame/schemas/1005.json b/src/js/savegame/schemas/1005.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/js/savegame/schemas/1005.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +}