You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tobspr_shapez.io/src/js/game/belt_path.js

1656 lines
60 KiB

import { globalConfig } from "../core/config";
import { smoothenDpi } from "../core/dpi_manager";
import { DrawParameters } from "../core/draw_parameters";
import { createLogger } from "../core/logging";
import { Rectangle } from "../core/rectangle";
import { ORIGINAL_SPRITE_SCALE } from "../core/sprites";
import { clamp, epsilonCompare, round4Digits } from "../core/utils";
import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../core/vector";
import { BasicSerializableObject, types } from "../savegame/serialization";
import { BaseItem } from "./base_item";
import { Entity } from "./entity";
import { typeItemSingleton } from "./item_resolver";
import { GameRoot } from "./root";
const logger = createLogger("belt_path");
// Helpers for more semantic access into interleaved arrays
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, typeItemSingleton)),
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;
}
/**
* @param {GameRoot} root
* @param {Array<Entity>} 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();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
this.debug_checkIntegrity("constructor");
}
}
/**
* Initializes the path by computing the properties which are not saved
* @param {boolean} computeSpacing Whether to also compute the spacing
*/
init(computeSpacing = true) {
this.onPathChanged();
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;
}
}
/**
* Clears all items
*/
clearAllItems() {
this.items = [];
this.spacingToFirstItem = this.totalLength;
this.numCompressedItemsAfterFirstItem = 0;
}
/**
* 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) {
// So, since we already need one tick to accept this item we will add this directly.
const beltProgressPerTick =
this.root.hubGoals.getBeltBaseSpeed() *
this.root.dynamicTickrate.deltaSeconds *
globalConfig.itemSpacingOnBelts;
// First, compute how much progress we can make *at max*
const maxProgress = Math.max(0, this.spacingToFirstItem - globalConfig.itemSpacingOnBelts);
const initialProgress = Math.min(maxProgress, beltProgressPerTick);
this.items.unshift([this.spacingToFirstItem - initialProgress, item]);
this.spacingToFirstItem = initialProgress;
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
this.debug_checkIntegrity("accept-item");
}
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);
}
/**
* Recomputes cache variables once the path was changed
*/
onPathChanged() {
this.boundAcceptor = this.computeAcceptingEntityAndSlot();
/**
* How many items past the first item are compressed
*/
this.numCompressedItemsAfterFirstItem = 0;
}
/**
* Called by the belt system when the surroundings changed
*/
onSurroundingsChanged() {
this.onPathChanged();
}
/**
* Finds the entity which accepts our items
* @param {boolean=} debug_Silent Whether debug output should be silent
* @return { (BaseItem, number?) => boolean }
*/
computeAcceptingEntityAndSlot(debug_Silent = false) {
DEBUG && !debug_Silent && logger.log("Recomputing acceptor target");
const lastEntity = this.entityPath[this.entityPath.length - 1];
const lastStatic = lastEntity.components.StaticMapEntity;
const lastBeltComp = lastEntity.components.Belt;
// Figure out where and into which direction we eject items
const ejectSlotWsTile = lastStatic.localTileToWorld(new Vector(0, 0));
const ejectSlotWsDirection = lastStatic.localDirectionToWorld(lastBeltComp.direction);
const ejectSlotWsDirectionVector = enumDirectionToVector[ejectSlotWsDirection];
const ejectSlotTargetWsTile = ejectSlotWsTile.add(ejectSlotWsDirectionVector);
// Try to find the given acceptor component to take the item
const targetEntity = this.root.map.getLayerContentXY(
ejectSlotTargetWsTile.x,
ejectSlotTargetWsTile.y,
"regular"
);
if (!targetEntity) {
return;
}
const noSimplifiedBelts = !this.root.app.settings.getAllSettings().simplifiedBelts;
DEBUG && !debug_Silent && logger.log(" Found target entity", targetEntity.uid);
const targetStaticComp = targetEntity.components.StaticMapEntity;
const targetBeltComp = targetEntity.components.Belt;
// Check for belts (special case)
if (targetBeltComp) {
const beltAcceptingDirection = targetStaticComp.localDirectionToWorld(enumDirection.top);
DEBUG &&
!debug_Silent &&
logger.log(
" Entity is accepting items from",
ejectSlotWsDirection,
"vs",
beltAcceptingDirection,
"Rotation:",
targetStaticComp.rotation
);
if (ejectSlotWsDirection === beltAcceptingDirection) {
return item => {
const path = targetBeltComp.assignedPath;
assert(path, "belt has no path");
return path.tryAcceptItem(item);
};
}
}
// Check for item acceptors
const targetAcceptorComp = targetEntity.components.ItemAcceptor;
if (!targetAcceptorComp) {
// Entity doesn't accept items
return;
}
const ejectingDirection = targetStaticComp.worldDirectionToLocal(ejectSlotWsDirection);
const matchingSlot = targetAcceptorComp.findMatchingSlot(
targetStaticComp.worldToLocalTile(ejectSlotTargetWsTile),
ejectingDirection
);
if (!matchingSlot) {
// No matching slot found
return;
}
const matchingSlotIndex = matchingSlot.index;
const passOver = this.computePassOverFunctionWithoutBelts(targetEntity, matchingSlotIndex);
if (!passOver) {
return;
}
const matchingDirection = enumInvertedDirections[ejectingDirection];
const filter = matchingSlot.slot.filter;
return function (item, remainingProgress = 0.0) {
// Check if the acceptor has a filter
if (filter && item._type !== filter) {
return false;
}
// Try to pass over
if (passOver(item, matchingSlotIndex)) {
// Trigger animation on the acceptor comp
if (noSimplifiedBelts) {
targetAcceptorComp.onItemAccepted(
matchingSlotIndex,
matchingDirection,
item,
remainingProgress
);
}
return true;
}
return false;
};
}
/**
* Computes a method to pass over the item to the entity
* @param {Entity} entity
* @param {number} matchingSlotIndex
* @returns {(item: BaseItem, slotIndex: number) => boolean | void}
*/
computePassOverFunctionWithoutBelts(entity, matchingSlotIndex) {
const systems = this.root.systemMgr.systems;
const hubGoals = this.root.hubGoals;
// NOTICE: THIS IS COPIED FROM THE ITEM EJECTOR SYSTEM FOR PEROFMANCE REASONS
const itemProcessorComp = entity.components.ItemProcessor;
if (itemProcessorComp) {
// Its an item processor ..
return function (item) {
// Check for potential filters
if (!systems.itemProcessor.checkRequirements(entity, item, matchingSlotIndex)) {
return;
}
return itemProcessorComp.tryTakeItem(item, matchingSlotIndex);
};
}
const undergroundBeltComp = entity.components.UndergroundBelt;
if (undergroundBeltComp) {
// Its an underground belt. yay.
return function (item) {
return undergroundBeltComp.tryAcceptExternalItem(
item,
hubGoals.getUndergroundBeltBaseSpeed()
);
};
}
const storageComp = entity.components.Storage;
if (storageComp) {
// It's a storage
return function (item) {
if (storageComp.canAcceptItem(item)) {
storageComp.takeItem(item);
return true;
}
};
}
const filterComp = entity.components.Filter;
if (filterComp) {
// It's a filter! Unfortunately the filter has to know a lot about it's
// surrounding state and components, so it can't be within the component itself.
return function (item) {
if (systems.filter.tryAcceptItem(entity, matchingSlotIndex, item)) {
return true;
}
};
}
}
// Following code will be compiled out outside of dev versions
/* dev:start */
/**
* Helper to throw an error on mismatch
* @param {string} change
* @param {Array<any>} 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") {
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 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[0 /* nextDistance */] < 0 || item[0 /* nextDistance */] > this.totalLength + 0.02) {
return fail(
"Item has invalid offset to next item: ",
item[0 /* nextDistance */],
"(total length:",
this.totalLength,
")"
);
}
currentPos += item[0 /* 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[0 /* nextDistance */]).join("|")
);
}
// Check bounds
const actualBounds = this.computeBounds();
if (!actualBounds.equalsEpsilon(this.worldBounds, 0.01)) {
return fail("Bounds are stale");
}
// Check acceptor
const acceptor = this.computeAcceptingEntityAndSlot(true);
if (!!acceptor !== !!this.boundAcceptor) {
return fail("Acceptor target mismatch, acceptor", !!acceptor, "vs stored", !!this.boundAcceptor);
}
// Check first nonzero offset
let firstNonzero = 0;
for (let i = this.items.length - 2; i >= 0; --i) {
if (this.items[i][0 /* nextDistance */] < globalConfig.itemSpacingOnBelts + 1e-5) {
++firstNonzero;
} else {
break;
}
}
// Should warn, but this check isn't actually accurate
// if (firstNonzero !== this.numCompressedItemsAfterFirstItem) {
// console.warn(
// "First nonzero index is " +
// firstNonzero +
// " but stored is " +
// this.numCompressedItemsAfterFirstItem
// );
// }
}
/* dev:end */
/**
* 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;
// Append the entity
this.entityPath.push(entity);
this.onPathChanged();
// 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[0 /* nextDistance */],
"to",
lastItem[0 /* nextDistance */] + additionalLength
);
lastItem[0 /* nextDistance */] += additionalLength;
}
// Assign reference
beltComp.assignedPath = this;
// Update bounds
this.worldBounds = this.computeBounds();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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.onPathChanged();
// Update bounds
this.worldBounds = this.computeBounds();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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[0 /* 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[0 /* 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[1 /* 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[0 /* nextDistance */] = clampedDistanceToNext;
}
}
// Advance items
itemPos += distanceToNext;
}
DEBUG &&
logger.log(
"New items are",
this.items.map(i => i[0 /* nextDistance */])
);
DEBUG &&
logger.log(
"And second path items are",
secondPath.items.map(i => i[0 /* 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;
}
this.onPathChanged();
secondPath.onPathChanged();
// Update bounds
this.worldBounds = this.computeBounds();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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();
this.onPathChanged();
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[0 /* nextDistance */]
);
lastItemOffset = itemOffset;
itemOffset += item[0 /* 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][0 /* 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 bounds
this.worldBounds = this.computeBounds();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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();
this.onPathChanged();
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[0 /* 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[0 /* 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 bounds
this.worldBounds = this.computeBounds();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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
);
// Now, update the distance of our last item
if (this.items.length !== 0) {
const lastItem = this.items[this.items.length - 1];
lastItem[0 /* nextDistance */] += otherPath.spacingToFirstItem;
DEBUG &&
logger.log(
" Add distance to last item, effectively being",
lastItem[0 /* 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[0 /* nextDistance */], item[1 /* item */]]);
}
// Update bounds
this.worldBounds = this.computeBounds();
this.onPathChanged();
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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) {
const entity = this.entityPath[i];
length += entity.components.Belt.getEffectiveLengthTiles();
}
return length;
}
/**
* Performs one tick
*/
update() {
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
this.debug_checkIntegrity("pre-update");
}
// Skip empty belts
if (this.items.length === 0) {
return;
}
// 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;
}
// Store whether this is the first item we processed, so premature
// item ejection is available
let isFirstItemProcessed = true;
// Store how much velocity (strictly its distance, not velocity) we have to distribute over all items
let remainingVelocity = beltSpeed;
// Store the last item we processed, so we can skip clashed ones
let lastItemProcessed;
for (lastItemProcessed = this.items.length - 1; lastItemProcessed >= 0; --lastItemProcessed) {
const nextDistanceAndItem = this.items[lastItemProcessed];
// Compute how much spacing we need at least
const minimumSpacing =
lastItemProcessed === this.items.length - 1 ? 0 : globalConfig.itemSpacingOnBelts;
// Compute how much we can advance
let clampedProgress = nextDistanceAndItem[0 /* nextDistance */] - minimumSpacing;
// Make sure we don't advance more than the remaining velocity has stored
if (remainingVelocity < clampedProgress) {
clampedProgress = remainingVelocity;
}
// Make sure we don't advance back
if (clampedProgress < 0) {
clampedProgress = 0;
}
// Reduce our velocity by the amount we consumed
remainingVelocity -= clampedProgress;
// Reduce the spacing
nextDistanceAndItem[0 /* nextDistance */] -= clampedProgress;
// Advance all items behind by the progress we made
this.spacingToFirstItem += clampedProgress;
// If the last item can be ejected, eject it and reduce the spacing, because otherwise
// we lose velocity
if (isFirstItemProcessed && nextDistanceAndItem[0 /* nextDistance */] < 1e-7) {
// Store how much velocity we "lost" because we bumped the item to the end of the
// belt but couldn't move it any farther. We need this to tell the item acceptor
// animation to start a tad later, so everything matches up. Yes I'm a perfectionist.
const excessVelocity = beltSpeed - clampedProgress;
// Try to directly get rid of the item
if (
this.boundAcceptor &&
this.boundAcceptor(nextDistanceAndItem[1 /* item */], excessVelocity)
) {
this.items.pop();
const itemBehind = this.items[lastItemProcessed - 1];
if (itemBehind && this.numCompressedItemsAfterFirstItem > 0) {
// So, with the next tick we will skip this item, but it actually has the potential
// to process farther -> If we don't advance here, we loose a tiny bit of progress
// every tick which causes the belt to be slower than it actually is.
// Also see #999
const fixupProgress = Math.max(
0,
Math.min(remainingVelocity, itemBehind[0 /* nextDistance */])
);
// See above
itemBehind[0 /* nextDistance */] -= fixupProgress;
remainingVelocity -= fixupProgress;
this.spacingToFirstItem += fixupProgress;
}
// Reduce the number of compressed items since the first item no longer exists
this.numCompressedItemsAfterFirstItem = Math.max(
0,
this.numCompressedItemsAfterFirstItem - 1
);
}
}
if (isFirstItemProcessed) {
// Skip N null items after first items
lastItemProcessed -= this.numCompressedItemsAfterFirstItem;
}
isFirstItemProcessed = false;
if (remainingVelocity < 1e-7) {
break;
}
}
// Compute compressed item count
this.numCompressedItemsAfterFirstItem = Math.max(
0,
this.numCompressedItemsAfterFirstItem,
this.items.length - 2 - lastItemProcessed
);
// Check if we have an item which is ready to be emitted
const lastItem = this.items[this.items.length - 1];
if (lastItem && lastItem[0 /* nextDistance */] === 0) {
if (this.boundAcceptor && this.boundAcceptor(lastItem[1 /* item */])) {
this.items.pop();
this.numCompressedItemsAfterFirstItem = Math.max(
0,
this.numCompressedItemsAfterFirstItem - 1
);
}
}
if (G_IS_DEV && globalConfig.debug.checkBeltPaths) {
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 issues ..
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[0 /* nextDistance */]),
worldPos.x + 5,
worldPos.y + 2
);
progress += nextDistanceAndItem[0 /* nextDistance */];
if (this.items.length - 1 - this.numCompressedItemsAfterFirstItem === i) {
parameters.context.fillStyle = "red";
parameters.context.fillRect(worldPos.x + 5, worldPos.y, 20, 3);
}
}
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);
}
/**
* Checks if this belt path should render simplified
*/
checkIsPotatoMode() {
// POTATO Mode: Only show items when belt is hovered
if (!this.root.app.settings.getAllSettings().simplifiedBelts) {
return false;
}
if (this.root.currentLayer !== "regular") {
// Not in regular layer
return true;
}
const mousePos = this.root.app.mousePosition;
if (!mousePos) {
// Mouse not registered
return true;
}
const tile = this.root.camera.screenToWorld(mousePos).toTileSpace();
const contents = this.root.map.getLayerContentXY(tile.x, tile.y, "regular");
if (!contents || !contents.components.Belt) {
// Nothing below
return true;
}
if (contents.components.Belt.assignedPath !== this) {
// Not this path
return true;
}
return false;
}
/**
* Draws the path
* @param {DrawParameters} parameters
*/
draw(parameters) {
if (!parameters.visibleRect.containsRect(this.worldBounds)) {
return;
}
if (this.items.length === 0) {
// Early out
return;
}
if (this.checkIsPotatoMode()) {
const firstItem = this.items[0];
if (this.entityPath.length > 1 && firstItem) {
const medianBeltIndex = clamp(
Math.round(this.entityPath.length / 2 - 1),
0,
this.entityPath.length - 1
);
const medianBelt = this.entityPath[medianBeltIndex];
const beltComp = medianBelt.components.Belt;
const staticComp = medianBelt.components.StaticMapEntity;
const centerPosLocal = beltComp.transformBeltToLocalSpace(
this.entityPath.length % 2 === 0 ? beltComp.getEffectiveLengthTiles() : 0.5
);
const centerPos = staticComp.localTileToWorld(centerPosLocal).toWorldSpaceCenterOfTile();
parameters.context.globalAlpha = 0.5;
firstItem[1 /* item */].drawItemCenteredClipped(centerPos.x, centerPos.y, parameters);
parameters.context.globalAlpha = 1;
}
return;
}
let currentItemPos = this.spacingToFirstItem;
let currentItemIndex = 0;
let trackPos = 0.0;
/**
* @type {Array<[Vector, BaseItem]>}
*/
let drawStack = [];
let drawStackProp = "";
// Iterate whole track and check items
for (let i = 0; i < this.entityPath.length; ++i) {
const entity = this.entityPath[i];
const beltComp = entity.components.Belt;
const beltLength = beltComp.getEffectiveLengthTiles();
// Check if the current items are on the belt
while (trackPos + beltLength >= currentItemPos - 1e-5) {
// It's on the belt, render it now
const staticComp = entity.components.StaticMapEntity;
assert(
currentItemPos - trackPos >= 0,
"invalid track pos: " + currentItemPos + " vs " + trackPos + " (l =" + beltLength + ")"
);
const localPos = beltComp.transformBeltToLocalSpace(currentItemPos - trackPos);
const worldPos = staticComp.localTileToWorld(localPos).toWorldSpaceCenterOfTile();
const distanceAndItem = this.items[currentItemIndex];
const item = distanceAndItem[1 /* item */];
const nextItemDistance = distanceAndItem[0 /* nextDistance */];
if (
!parameters.visibleRect.containsCircle(
worldPos.x,
worldPos.y,
globalConfig.defaultItemDiameter
)
) {
// this one isn't visible, do not append it
// Start a new stack
this.drawDrawStack(drawStack, parameters, drawStackProp);
drawStack = [];
drawStackProp = "";
} else {
if (drawStack.length > 1) {
// Check if we can append to the stack, since its already a stack of two same items
const referenceItem = drawStack[0];
if (
referenceItem[1].equals(item) &&
Math.abs(referenceItem[0][drawStackProp] - worldPos[drawStackProp]) < 0.001
) {
// Will continue stack
} else {
// Start a new stack, since item doesn't follow in row
this.drawDrawStack(drawStack, parameters, drawStackProp);
drawStack = [];
drawStackProp = "";
}
} else if (drawStack.length === 1) {
const firstItem = drawStack[0];
// Check if we can make it a stack
if (firstItem[1 /* item */].equals(item)) {
// Same item, check if it is either horizontal or vertical
const startPos = firstItem[0 /* pos */];
if (Math.abs(startPos.x - worldPos.x) < 0.001) {
drawStackProp = "x";
} else if (Math.abs(startPos.y - worldPos.y) < 0.001) {
drawStackProp = "y";
} else {
// Start a new stack
this.drawDrawStack(drawStack, parameters, drawStackProp);
drawStack = [];
drawStackProp = "";
}
} else {
// Start a new stack, since item doesn't equal
this.drawDrawStack(drawStack, parameters, drawStackProp);
drawStack = [];
drawStackProp = "";
}
} else {
// First item of stack, do nothing
}
drawStack.push([worldPos, item]);
}
// Check for the next item
currentItemPos += nextItemDistance;
++currentItemIndex;
if (
nextItemDistance > globalConfig.itemSpacingOnBelts + 0.001 ||
drawStack.length > globalConfig.maxBeltShapeBundleSize
) {
// If next item is not directly following, abort drawing
this.drawDrawStack(drawStack, parameters, drawStackProp);
drawStack = [];
drawStackProp = "";
}
if (currentItemIndex >= this.items.length) {
// We rendered all items
this.drawDrawStack(drawStack, parameters, drawStackProp);
return;
}
}
trackPos += beltLength;
}
this.drawDrawStack(drawStack, parameters, drawStackProp);
}
/**
*
* @param {HTMLCanvasElement} canvas
* @param {CanvasRenderingContext2D} context
* @param {number} w
* @param {number} h
* @param {number} dpi
* @param {object} param0
* @param {string} param0.direction
* @param {Array<[Vector, BaseItem]>} param0.stack
* @param {GameRoot} param0.root
* @param {number} param0.zoomLevel
*/
drawShapesInARow(canvas, context, w, h, dpi, { direction, stack, root, zoomLevel }) {
context.scale(dpi, dpi);
if (G_IS_DEV && globalConfig.debug.showShapeGrouping) {
context.fillStyle = "rgba(0, 0, 255, 0.5)";
context.fillRect(0, 0, w, h);
}
const parameters = new DrawParameters({
context,
desiredAtlasScale: ORIGINAL_SPRITE_SCALE,
root,
visibleRect: new Rectangle(-1000, -1000, 2000, 2000),
zoomLevel,
});
const itemSize = globalConfig.itemSpacingOnBelts * globalConfig.tileSize;
const item = stack[0];
const pos = new Vector(itemSize / 2, itemSize / 2);
for (let i = 0; i < stack.length; i++) {
item[1].drawItemCenteredClipped(pos.x, pos.y, parameters, globalConfig.defaultItemDiameter);
pos[direction] += globalConfig.itemSpacingOnBelts * globalConfig.tileSize;
}
}
/**
* @param {Array<[Vector, BaseItem]>} stack
* @param {DrawParameters} parameters
*/
drawDrawStack(stack, parameters, directionProp) {
if (stack.length === 0) {
return;
}
const firstItem = stack[0];
const firstItemPos = firstItem[0];
if (stack.length === 1) {
firstItem[1].drawItemCenteredClipped(
firstItemPos.x,
firstItemPos.y,
parameters,
globalConfig.defaultItemDiameter
);
return;
}
const itemSize = globalConfig.itemSpacingOnBelts * globalConfig.tileSize;
const inverseDirection = directionProp === "x" ? "y" : "x";
const dimensions = new Vector(itemSize, itemSize);
dimensions[inverseDirection] *= stack.length;
const directionVector = firstItemPos.copy().sub(stack[1][0]);
const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel);
const sprite = this.root.buffers.getForKey({
key: "beltpaths",
subKey:
"stack-" +
directionProp +
"-" +
dpi +
"#" +
stack.length +
"#" +
firstItem[1].getItemType() +
"#" +
firstItem[1].serialize(),
dpi,
w: dimensions.x,
h: dimensions.y,
redrawMethod: this.drawShapesInARow.bind(this),
additionalParams: {
direction: inverseDirection,
stack,
root: this.root,
zoomLevel: parameters.zoomLevel,
},
});
const anchor = directionVector[inverseDirection] < 0 ? firstItem : stack[stack.length - 1];
parameters.context.drawImage(
sprite,
anchor[0].x - itemSize / 2,
anchor[0].y - itemSize / 2,
dimensions.x,
dimensions.y
);
}
}