@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
1,035 lines (813 loc) • 25.2 kB
JavaScript
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;