UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,035 lines (813 loc) • 25.2 kB
import { assert } from "../../assert.js"; import Signal from "../../events/signal/Signal.js"; import { objectsEqual } from "../../function/objectsEqual.js"; import { invokeObjectEquals } from "../../model/object/invokeObjectEquals.js"; import { array_index_by_equality } from "../array/array_index_by_equality.js"; import { array_set_diff } from "../array/array_set_diff.js"; /** * * List structure with event signals for observing changes. * @template T * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ class List { /** * @readonly */ on = { /** * @readonly * @type {Signal<T,number>} */ added: new Signal(), /** * @readonly * @type {Signal<T,number>} */ removed: new Signal(), /** * Captures both {@link #on.added} and {@link #on.removed} signals. * Useful shortcut if you don't care about actual modifications, just the fact that something has changed. * @readonly * @type {Signal<>} */ get changed() { // lazily initialization, don't want to create the signal and capture events if we don't need it const signal = new Signal(); // pipe changes to an aggregate signal const h = () => signal.send0(); this.added.add(h); this.removed.add(h); // rewrite the property to return the aggregate signal Object.defineProperty(this, 'changed', { value: signal, writable: false, enumerable: true, configurable: false }); return signal; } }; /** * @readonly * @type {T[]} */ data = [] /** * @param {T[]} [array] */ constructor(array) { if (array !== undefined) { assert.isArray(array, 'array'); /** * @private * @readonly * @type {T[]} */ this.data = array.slice(); } /** * Number of elements in the list * @type {number} */ this.length = this.data.length; } /** * Retrieve element at a given position in the list * @param {number} index * @returns {T|undefined} */ get(index) { assert.isNumber(index, 'index'); assert.isNonNegativeInteger(index, 'index'); return this.data[index]; } /** * Set element at a given position inside the list * @param {number} index * @param {T} value */ set(index, value) { assert.isNumber(index, 'index'); assert.isNonNegativeInteger(index, 'index'); const oldValue = this.data[index]; if (oldValue !== undefined) { this.on.removed.send2(oldValue, index) } else { if (index >= this.length) { this.length = index + 1; if (index > this.length) { console.error(`Overflow, attempted to set element at ${index} past the list length(=${this.length})`); } } } this.data[index] = value; this.on.added.send2(value, index); } /** * * @param {T} el * @returns {this} */ add(el) { this.data.push(el); const oldLength = this.length; this.length = oldLength + 1; this.on.added.send2(el, oldLength); return this; } /** * Only add element if it doesn't already exist in the list * Useful when you wish to ensure uniqueness of elements inside the list * Note that this operation is rather slow as it triggers a linear scan * If the list gets large - consider using a {@link Set} class instead * @param {T} el * @return {boolean} true if element was added, false if element already existed in the list */ addUnique(el) { if (this.contains(el)) { return false; } this.add(el); return true; } /** * Insert element at a specific position into the list * This operation will result in a larger list * @param {number} index * @param {T} el * @returns {List} * @throws {Error} when trying to insert past list end */ insert(index, el) { if (index > this.length) { console.error(`Overflow, attempted to insert element at ${index} past the list length(=${this.length})`); this.length = index; this.data[index] = el; } else { this.data.splice(index, 0, el); } this.length++; this.on.added.send2(el, index); return this; } /** * Reduces the list to a subsection but removing everything before startIndex and everything after endIndex * @param {int} startIndex * @param {int} endIndex up to this index, not including it * @returns {number} */ crop(startIndex, endIndex) { const data = this.data; const tail = data.splice(endIndex, this.length - endIndex); const head = data.splice(0, startIndex); this.length = endIndex - startIndex; const headLength = head.length; const tailLength = tail.length; const onRemoved = this.on.removed; if (onRemoved.hasHandlers()) { let i; for (i = 0; i < headLength; i++) { onRemoved.send2(head[i], i); } for (i = 0; i < tailLength; i++) { onRemoved.send2(tail[i], endIndex + i); } } //return number of dropped elements return headLength + tailLength; } /** * Replace the data, replacements is performed surgically, meaning that diff is computed and add/remove operations are performed on the set * This method is tailored to work well with visualisation as only elements that's missing from the new set is removed, and only elements that are new to are added * Conversely, relevant events are dispatched that can observe. This results in fewer changes required to the visualisation * @param {T[]} new_data */ patch(new_data) { const data = this.data; const diff = array_set_diff(data, new_data, objectsEqual); //resolve diff const removals = diff.uniqueA; const removalCount = removals.length; for (let i = 0; i < removalCount; i++) { const item = removals[i]; this.removeOneOf(item); } const additions = diff.uniqueB; //sort additions by their position in the input additions.sort((a, b) => { const ai = new_data.indexOf(a); const bi = new_data.indexOf(b); return bi - ai; }); const additionsCount = additions.length; for (let i = 0; i < additionsCount; i++) { const item = additions[i]; //find where the item is in the input const inputIndex = new_data.indexOf(item); let p = 0; for (let i = inputIndex - 1; i >= 0; i--) { const prev = new_data[i]; //look for previous item in the output const j = this.indexOf(prev); if (j !== -1) { p = j + 1; break; } } this.insert(p, item); } } /** * * @param {Array.<T>} elements */ addAll(elements) { const addedElementsCount = elements.length; const added = this.on.added; if (added.hasHandlers()) { //only signal if there are listeners attached for (let i = 0; i < addedElementsCount; i++) { const element = elements[i]; this.data.push(element); added.send2(element, this.length++); } } else { //no observers, we can add elements faster Array.prototype.push.apply(this.data, elements); this.length += addedElementsCount; } } /** * * @param {Array.<T>} elements * @see addUnique * @see addAll */ addAllUnique(elements) { const length = elements.length; for (let i = 0; i < length; i++) { this.addUnique(elements[i]); } } /** * * @param {number} index * @param {number} removeCount * @returns {T[]} */ removeMany(index, removeCount) { assert.isNonNegativeInteger(index, "index"); assert.ok(index < this.length || index < 0, `index(=${index}) out of range (${this.length})`); const removed_elements = this.data.splice(index, removeCount); const removedCount = removed_elements.length; this.length -= removedCount; assert.equal(this.length, this.data.length, `length(=${this.length}) is inconsistent with underlying data array length(=${this.data.length})`) const onRemoved = this.on.removed; if (onRemoved.hasHandlers()) { for (let i = 0; i < removedCount; i++) { const element = removed_elements[i]; onRemoved.send2(element, index + i); } } return removed_elements; } /** * * @param {number} index * @returns {T} */ remove(index) { assert.equal(typeof index, 'number', `index must a number, instead was '${typeof index}'`); assert.ok(index < this.length || index < 0, `index(=${index}) out of range (${this.length})`); const els = this.data.splice(index, 1); this.length--; assert.equal(this.length, this.data.length, `length(=${this.length}) is inconsistent with underlying data array length(=${this.data.length})`) const element = els[0]; this.on.removed.send2(element, index); return element; } /** * * @param {T[]} elements * @returns {boolean} True is all specified elements were found and removed, False if some elements were not present in the list */ removeAll(elements) { let i, il; let j, jl; il = elements.length; jl = this.length; let missCount = 0; const data = this.data; main_loop: for (i = 0; i < il; i++) { const expected = elements[i]; for (j = jl - 1; j >= 0; j--) { const actual = data[j]; if (objectsEqual(actual, expected)) { this.remove(j); jl--; continue main_loop; } } missCount++; } //some elements were not deleted return missCount === 0; } /** * * @param {T} value * @return {boolean} */ removeOneOf(value) { if (typeof value === "object" && typeof value.equals === "function") { return this.removeOneIf(conditionEqualsViaMethod, value); } else { return this.removeOneIf(conditionEqualsStrict, value); } } /** * * @param {function(a:T, b:T):number} [compare_function] * @returns {this} */ sort(compare_function) { Array.prototype.sort.call(this.data, compare_function); return this; } /** * Copy of this list * The copy is shallow * @returns {List.<T>} */ clone() { return new List(this.data); } /** * Returns a shallow copy array with elements in range from start to end (end not included) * Same as {@link Array.prototype.slice} * @param {number} [start] * @param {number} [end] * @return {T[]} */ slice(start, end) { return this.data.slice(start, end); } /** * * @param {function(element:T):boolean} condition * @returns {boolean} */ some(condition) { const l = this.length; const data = this.data; for (let i = 0; i < l; i++) { if (condition(data[i])) { return true; } } return false; } /** * * @param {function(T):boolean} condition must return boolean value * @param {*} [thisArg] * @see removeOneIf */ removeIf(condition, thisArg) { assert.isFunction(condition, 'condition'); let l = this.length; const data = this.data; for (let i = 0; i < l; i++) { const element = data[i]; if (condition.call(thisArg, element)) { this.remove(i); i--; l--; } } } /** * * @param {function(T):boolean} condition * @param {*} [thisArg] * @return {boolean} * @see removeIf */ removeOneIf(condition, thisArg) { const l = this.length; const data = this.data; for (let i = 0; i < l; i++) { const element = data[i]; if (condition.call(thisArg, element)) { this.remove(i); return true; } } return false; } /** * INVARIANT: List length must not change during the traversal * @param {function(el:T, index:number):?} f * @param {*} [thisArg] */ forEach(f, thisArg) { const l = this.length; const data = this.data; for (let i = 0; i < l; i++) { f.call(thisArg, data[i], i); } } /** * @param {function(*,T):*} f * @param {*} initial * @returns {*} */ reduce(f, initial) { let t = initial; this.forEach(function (v) { t = f(t, v); }); return t; } /** * * @param {function(T):boolean} f * @returns {Array.<T>} */ filter(f) { return this.data.filter(f); } /** * * @param {function(el:T):boolean} matcher * @returns {T|undefined} */ find(matcher) { const data = this.data; let i = 0; const l = this.length; for (; i < l; i++) { const el = data[i]; if (matcher(el)) { return el; } } return undefined; } /** * * @param {function(T):boolean} matcher * @returns {number} Index of the first match or -1 */ findIndex(matcher) { const data = this.data; let i = 0; const l = this.length; for (; i < l; i++) { const el = data[i]; if (matcher(el)) { return i; } } return -1; } /** * * @param {function(el:T):boolean} matcher * @param {function(el:T, index:number):*} callback * @returns {boolean} */ visitFirstMatch(matcher, callback) { const index = this.findIndex(matcher); if (index === -1) { return false; } const el = this.data[index]; if (matcher(el)) { callback(el, index); return true; } else { return false; } } /** * * @param {T} v * @returns {boolean} */ contains(v) { return this.data.indexOf(v) !== -1; } /** * Does the list contain at least one of the given options? * @param {T[]} options * @returns {boolean} */ containsAny(options) { const n = options.length; for (let i = 0; i < n; i++) { const option = options[i]; if (this.contains(option)) { return true; } } return false; } /** * List has no elements * @returns {boolean} */ isEmpty() { return this.length <= 0; } /** * * @param {T} el * @returns {number} */ indexOf(el) { return this.data.indexOf(el); } /** * @template R * @param {function(T):R} callback * @param {*} [thisArg] * @returns {R[]} */ map(callback, thisArg) { const result = []; const data = this.data; const l = this.length; for (let i = 0; i < l; i++) { const datum = data[i]; if (datum !== undefined) { result[i] = callback.call(thisArg, datum, i); } } return result; } /** * @deprecated use `#reset` directly in combination with `this.on.removed` signal * @param {function(element:T,index:number)} callback * @param {*} [thisArg] */ resetViaCallback(callback, thisArg) { throw new Error('deprecated'); } /** * Clears the list and removes all elements */ reset() { const length = this.length; if (length > 0) { const removed = this.on.removed; if (removed.hasHandlers()) { const oldElements = this.data; //only signal if there are listeners attached for (let i = length - 1; i >= 0; i--) { const element = oldElements[i]; // decrement data length gradually to allow handlers access to the rest of the elements this.data.length = i; this.length = i; removed.send2(element, i); } } else { this.data = []; this.length = 0; } } } /** * Note that elements must have .clone method for this to work * @param {List<T>} other * @param {function} [removeCallback] * @param {*} [thisArg] Used on removeCallback */ deepCopy(other, removeCallback, thisArg) { assert.notEqual(other, undefined, 'other is undefined'); assert.notEqual(other, null, 'other is null'); const newData = []; const otherItems = other.asArray(); const nOtherItems = otherItems.length; const thisItems = this.data; let nThisItems = this.length; //walk existing elements and see what can be kept for (let i = 0; i < nThisItems; i++) { const a = thisItems[i]; const index = array_index_by_equality(otherItems, a, invokeObjectEquals); if (index !== -1) { newData[index] = a; } else if (typeof removeCallback === "function") { removeCallback.call(thisArg, a); } } //fill in the blanks for (let i = 0; i < nOtherItems; i++) { if (newData[i] === undefined) { const otherItem = otherItems[i]; newData[i] = otherItem.clone(); } } this.reset(); this.addAll(newData); } /** * * @param {List<T>|T[]} other */ copy(other) { if (this === other) { // no point return; } this.reset(); if (other.length > 0) { if (other instanceof List) { this.addAll(other.data); } else { this.addAll(other); } } } /** * NOTE: do not modify resulting array * @returns {T[]} */ asArray() { return this.data; } toJSON() { return JSON.parse(JSON.stringify(this.data)); } /** * @template J * @param {J[]} json * @param {function} constructor */ fromJSON(json, constructor) { this.reset(); assert.isArray(json, 'json'); if (typeof constructor === "function") { this.addAll(json.map(function (elJSON) { const el = new constructor(); el.fromJSON(elJSON); return el; })); } else { this.addAll(json); } } /** * * @param {BinaryBuffer} buffer */ toBinaryBuffer(buffer) { const n = this.length; buffer.writeUint32(n); for (let i = 0; i < n; i++) { const item = this.data[i]; if (typeof item.toBinaryBuffer !== "function") { throw new Error('item.toBinaryBuffer is not a function'); } item.toBinaryBuffer(buffer); } } /** * * @param {BinaryBuffer} buffer * @param constructor */ fromBinaryBuffer(buffer, constructor) { this.fromBinaryBufferViaFactory(buffer, function (buffer) { const el = new constructor(); if (typeof el.fromBinaryBuffer !== "function") { throw new Error('item.fromBinaryBuffer is not a function'); } el.fromBinaryBuffer(buffer); return el; }); } /** * * @param {BinaryBuffer} buffer * @param {function(buffer:BinaryBuffer)} factory */ fromBinaryBufferViaFactory(buffer, factory) { this.reset(); this.addFromBinaryBufferViaFactory(buffer, factory); } /** * * @param {BinaryBuffer} buffer * @param {function(buffer:BinaryBuffer)} factory */ addFromBinaryBufferViaFactory(buffer, factory) { const length = buffer.readUint32(); for (let i = 0; i < length; i++) { const el = factory(buffer); this.add(el); } } /** * @template J * @param {J[]} data * @param {function(J):T} factory */ addFromJSONViaFactory(data, factory) { const n = data.length; for (let i = 0; i < n; i++) { const datum = data[i]; const el = factory(datum); this.add(el); } } /** * NOTE: Elements must have `hash` method for this to work * * @returns {number} */ hash() { const length = this.length; let hash = length; for (let i = 0; i < length; i++) { const datum = this.data[i]; const singleValue = datum.hash(); hash = ((hash << 5) - hash) + singleValue; hash |= 0; // Convert to 32bit integer } return hash; } /** * First element in the list * @returns {T|undefined} */ first() { return this.get(0); } /** * Last element in the list * @return {T|undefined} */ last() { return this.get(this.length - 1); } /** * Perform element-wise equality comparison with another list * @param {List} other * @returns {boolean} */ equals(other) { assert.defined(other, 'other'); const length = this.length; if (length !== other.length) { return false; } let i; for (i = 0; i < length; i++) { const a = this.get(i); const b = other.get(i); if (a === b) { continue; } if (typeof a === "object" && typeof b === "object" && typeof a.equals === "function" && a.equals(b)) { //test via "equals" method continue; } //elements not equal return false; } return true; } /** * * @param {List<T>} other * @returns {number} */ compare(other) { const l = this.length; const other_length = other.length; if (l !== other_length) { return l - other_length; } for (let i = 0; i < l; i++) { const a = this.get(i); const b = other.get(i); const delta = a.compare(b); if (delta !== 0) { return delta; } } // same return 0; } } function conditionEqualsViaMethod(v) { return this === v || this.equals(v); } function conditionEqualsStrict(v) { return this === v; } export default List;