@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
930 lines (715 loc) • 25.5 kB
JavaScript
import { assert } from "../../assert.js";
import { ctz32 } from "../../binary/ctz32.js";
import { ceilPowerOfTwo } from "../../binary/operations/ceilPowerOfTwo.js";
import { isPowerOfTwo } from "../../math/isPowerOfTwo.js";
import { min2 } from "../../math/min2.js";
import { invokeObjectEquals } from "../../model/object/invokeObjectEquals.js";
import { invokeObjectHash } from "../../model/object/invokeObjectHash.js";
import { array_copy } from "../array/array_copy.js";
import { UintArrayForCount } from "../array/typed/uint_array_for_count.js";
/*
* Heavily inspired by ruby's "new" (circa 2016) hash table implementation
* @see https://github.com/ruby/ruby/blob/82995d4615e993f1d13f3e826b93fbd65c47e19e/st.c
* @see https://blog.heroku.com/ruby-2-4-features-hashes-integers-rounding#hash-changes
*/
/**
* Formula: Xn+1 = (a * Xn + c ) % m
*
* According the Hull-Dobell theorem a generator
* "Xnext = (a*Xprev + c) mod m" is a full cycle generator if and only if
* o m and c are relatively prime
* o a-1 is divisible by all prime factors of m
* o a-1 is divisible by 4 if m is divisible by 4.
* For our case a is 5, c is 1, and m is a power of two.
* @param index
* @param mask used to execute division, this is a number equal to (2^K - 1) where 2^K is the size of the "bins" array
* @see https://en.wikipedia.org/wiki/Linear_congruential_generator
*/
export function generate_next_linear_congruential_index(index, mask) {
const index5 = (index << 2) + index; // this is just a faster version of index*5
return (index5 + 1) & mask;
}
/**
* @template K,V
*/
class HashMapEntry {
/**
*
* @param {K} key
* @param {V} value
* @param {number} hash
*/
constructor(key, value, hash) {
/**
*
* @type {K}
*/
this.key = key;
/**
*
* @type {V}
*/
this.value = value;
/**
*
* @type {number}
*/
this.hash = hash;
}
}
HashMapEntry.prototype.isHashMapEntry = true;
/**
* @readonly
* @type {number}
*/
const DEFAULT_INITIAL_CAPACITY_POWER = 4;
/**
* @readonly
* @type {number}
*/
const DEFAULT_INITIAL_CAPACITY = 2 ** DEFAULT_INITIAL_CAPACITY_POWER;
/**
* @readonly
* @type {number}
*/
const DEFAULT_LOAD_FACTOR = 0.75;
/**
* Reserved value that we store in "bins" array to indicate an empty slot
* @type {number}
*/
const BIN_RESERVED_VALUE_EMPTY = 0;
/**
* Reserved value that we store in "bins" array to indicate a deleted entry
* @type {number}
*/
const BIN_RESERVED_VALUE_DELETED = 1;
/**
* Real index offset into entry array
* @type {number}
*/
const ENTRY_BASE = 2;
/**
* Special hash value used to indicate "dead" entries
* If key hashes to this value - we will replace it
* @type {number}
*/
const RESERVED_HASH = 4294967295;
/**
* Used as a replacement for reserved hash
* @type {number}
*/
const RESERVED_HASH_SUBSTITUTE = 0;
/**
*
* @type {number}
*/
const UNDEFINED_BIN_INDEX = ~0;
const EMPTY_BINS = new Uint32Array(0);
/**
* Implements part of {@link Map} interface
* NOTE: as with any hash-based data structure, keys are assumed to be immutable. If you modified keys after inserting them into the map, it will cause the hash table to become invalid. You can fix this by forcing rehashing, but generally - try to avoid changing keys in the first place.
*
* @template K,V
* @extends Map<K,V>
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class HashMap {
/**
* Index pointers to entries array,
* number of bins is always power or two
* @type {Uint32Array}
* @private
*/
__bins = EMPTY_BINS;
/**
* Note that dead entries are marked as such with a special reserved hash values, so records can be reused for new entries
* @type {Array<HashMapEntry<K,V>>}
* @private
*/
__entries = new Array(0);
/**
* Pointer to the end of allocated entries segment
* @type {number}
* @private
*/
__entries_bound = 0;
/**
*
* @type {number}
* @private
*/
__entries_start = 0;
/**
* number of records in the map
* @type {number}
* @private
*/
__size = 0;
/**
*
* @type {number}
* @private
*/
__bin_count = 0;
/**
* Always exactly half of the number of bins
* @type {number}
* @private
*/
__entries_allocated_count = 0;
/**
*
* @type {number}
* @private
*/
__bin_count_power_of_two = 0;
/**
*
* @type {number}
* @private
*/
__entries_count_power_of_two = 0;
/**
* Mask used to map from hash to a bin index
* @type {number}
* @private
*/
__bin_count_mask = 0;
/**
* How full the table can get before number of buckets is increased
* @type {number}
* @private
*/
__load_factor = DEFAULT_LOAD_FACTOR;
/**
* Used to track modifications to prevent concurrent changes during iteration
* @private
* @type {number}
*/
__version = 0;
/**
* @template K, V
* @param {function(K):number} [keyHashFunction] function to compute hash of a given key
* @param {function(K,K):boolean} [keyEqualityFunction] function to compute equality between two keys
* @param {number} [capacity] initial number of buckets in the hash table
* @param {number} [loadFactor] a measure of how full the hash table is allowed to get before its capacity is automatically increased
*/
constructor({
keyHashFunction = invokeObjectHash,
keyEqualityFunction = invokeObjectEquals,
capacity = DEFAULT_INITIAL_CAPACITY,
loadFactor = DEFAULT_LOAD_FACTOR
} = {}) {
assert.isFunction(keyHashFunction, 'keyHashFunction');
assert.isFunction(keyEqualityFunction, 'keyEqualityFunction');
assert.isNonNegativeInteger(capacity, 'capacity');
assert.isNumber(loadFactor, 'loadFactor');
assert.notNaN(loadFactor, 'loadFactor');
assert.greaterThan(loadFactor, 0, 'loadFactor must be > 0');
/**
*
* @type {function(K): number}
* @readonly
* @private
*/
this.keyHashFunction = keyHashFunction;
/**
*
* @type {function(K, K): boolean}
* @readonly
* @private
*/
this.keyEqualityFunction = keyEqualityFunction;
this.__load_factor = loadFactor;
this.#setBinCount(ceilPowerOfTwo(capacity));
}
/**
*
* @return {number}
*/
get size() {
return this.__size;
}
/**
* Proportion between actual number of records in the map and the bucket count (number of potential hashes)
* @returns {number}
*/
getCurrentLoad() {
return this.__size / this.__bin_count;
}
/**
* Note: this method is not intended for public use
* @param {number} count
*/
#setBinCount(count) {
assert.greaterThanOrEqual(count, 1, 'bucket count must be at least 1');
assert.isNonNegativeInteger(count, 'count');
assert.equal(isPowerOfTwo(count), true, `count must be a power of two, instead was ${count}`);
if (count < this.__size) {
throw new Error(`count must be at least equal to must of records in the map (=${this.__size}), instead was ${count}`);
}
this.__entries_count_power_of_two = ctz32(count);
this.__bin_count_power_of_two = this.__entries_count_power_of_two + 1;
this.__bin_count = 2 ** this.__bin_count_power_of_two;
this.__bin_count_mask = this.__bin_count - 1;
const old_entry_allocation_count = this.__entries_allocated_count;
this.__entries_allocated_count = 2 ** this.__entries_count_power_of_two;
const BinsArray = UintArrayForCount(this.__entries_allocated_count + ENTRY_BASE);
this.__bins = new BinsArray(this.__bin_count);
const new_entries = new Array(this.__entries_allocated_count);
const old_entries = this.__entries;
this.__entries = new_entries;
array_copy(old_entries, 0, new_entries, 0, min2(old_entry_allocation_count, this.__entries_allocated_count));
if (this.__size > 0) {
// re-hash
this.rebuild();
}
}
/**
* Given a hash, compute index of a bucket to which this hash belongs
* @param {number} hash
* @returns {number}
* @private
*/
compute_bin_index(hash) {
assert.isInteger(hash, 'hash');
// mix the input hash to minimize potential impact of poor hash function spread
const mixed_hash = (hash >>> 16) ^ hash;
// force index to unsigned integer
const index = mixed_hash >>> 0;
return index & this.__bin_count_mask;
}
/**
*
* @param {K} key
* @return {number}
*/
#build_key_hash(key) {
const original = this.keyHashFunction(key);
assert.isInteger(original, 'hash');
return original === RESERVED_HASH ? RESERVED_HASH_SUBSTITUTE : original;
}
/**
* @param {HashMapEntry<K,V>} record
* @param {number} hash
* @param {K} key
*/
#entry_equality_check(record, hash, key) {
if (record.hash !== hash) {
return false;
}
if (record.key === key) {
return true;
}
// assert.equal(record.hash, this.#build_key_hash(record.key), `Key hash has diverged for key ${record.key}, likely key was mutated or hash function is unstable`);
const result = this.keyEqualityFunction(record.key, key);
// assert.isBoolean(result, `result(a=${record.key},b=${key})`);
return result;
}
/**
*
* @param {K} k
* @param {V} v
* @param {number} hash
* @return {number}
*/
#allocate_entry(k, v, hash) {
const i = this.__entries_bound;
this.__entries_bound++;
const existing_entry = this.__entries[i];
if (existing_entry !== undefined) {
assert.equal(existing_entry.isHashMapEntry, true, 'existing_entry.isHashMapEntry !== true');
// entry exists, let's reuse it
assert.equal(existing_entry.hash, RESERVED_HASH, 'Entry is occupied');
existing_entry.hash = hash;
existing_entry.key = k;
existing_entry.value = v;
} else {
// entry slot is empty
this.__entries[i] = new HashMapEntry(k, v, hash);
}
return i;
}
/**
*
* @param {HashMapEntry<K,V>} entry
*/
#deallocate(entry) {
// clear out entry to allow values/keys to be garbage collected
entry.key = null;
entry.value = null;
entry.hash = RESERVED_HASH; // mark as dead via hash
}
#rebuild_if_necessary() {
if (this.__entries_bound !== this.__entries_allocated_count) {
return;
}
if (this.__size === this.__entries_allocated_count) {
// used up all allocated entries
// bin count must always be larger than end of the entries table
this.#grow();
} else {
// exhausted entries array, perform compaction
this.rebuild();
}
}
/**
*
* @param {K} key
* @param {V} value
*/
set(key, value) {
this.#rebuild_if_necessary();
const raw_hash = this.#build_key_hash(key);
let bin_index = this.compute_bin_index(raw_hash);
assert.isFiniteNumber(bin_index, 'hash');
let first_deleted_bin_index = UNDEFINED_BIN_INDEX;
for (; ;) {
const bin = this.__bins[bin_index];
if (bin > BIN_RESERVED_VALUE_DELETED) {
// bin is occupied
// check if it's the entry that we're looking for
const entry = this.__entries[bin - ENTRY_BASE];
if (this.#entry_equality_check(entry, raw_hash, key)) {
// found the right entry
entry.value = value;
return;
}
} else if (bin === BIN_RESERVED_VALUE_EMPTY) {
// bin is empty
if (first_deleted_bin_index !== UNDEFINED_BIN_INDEX) {
// reused bin of deleted entity
bin_index = first_deleted_bin_index;
}
const entry_index = this.#allocate_entry(key, value, raw_hash);
assert.defined(this.__entries[entry_index], 'entry');
assert.equal(this.__entries[entry_index].hash, raw_hash, 'entry.hash');
assert.equal(this.__entries[entry_index].value, value, 'entry.value');
assert.equal(this.__entries[entry_index].key, key, 'entry.key');
const bin_value = entry_index + ENTRY_BASE;
this.__bins[bin_index] = bin_value;
assert.equal(bin_value, this.__bins[bin_index], 'Bin value write error');
break;
} else if (first_deleted_bin_index === UNDEFINED_BIN_INDEX) {
// bin is deleted
first_deleted_bin_index = bin_index;
}
// perform secondary hashing
bin_index = generate_next_linear_congruential_index(bin_index, this.__bin_count_mask);
}
const old_size = this.__size;
const new_size = old_size + 1;
this.__size = new_size;
// compute actual current load
const bucket_count = this.__bin_count;
const load = new_size / bucket_count;
if (load > this.__load_factor) {
// current load is too high, increase table size
this.#grow();
}
}
/**
*
* @param {K} key
* @returns {V|undefined}
*/
get(key) {
const raw_hash = this.#build_key_hash(key);
let bin_index = this.compute_bin_index(raw_hash);
for (; ;) {
const bin = this.__bins[bin_index];
if (bin > BIN_RESERVED_VALUE_DELETED) {
// bin is occupied
// check if the entry is what we're looking for
const entry = this.__entries[bin - ENTRY_BASE];
if (this.#entry_equality_check(entry, raw_hash, key)) {
// found the right entry
return entry.value;
}
} else if (bin === BIN_RESERVED_VALUE_EMPTY) {
// bin is empty
return undefined;
}
// perform secondary hashing
bin_index = generate_next_linear_congruential_index(bin_index, this.__bin_count_mask);
}
}
/**
* If key exists in the map - returns stored value, if it doesn't - invokes `compute` function and stores produced value in the map; then returns that value
*
* Inspired by Java's implementation
* @param {K} key
* @param {function(K):V} compute
* @param {*} [compute_context]
* @return {V}
*/
getOrCompute(key, compute, compute_context) {
assert.isFunction(compute, 'compute');
const existing = this.get(key);
if (existing !== undefined) {
return existing;
}
const value = compute.call(compute_context, key);
this.set(key, value);
return value;
}
/**
* Returns existing value if key exists, if it doesn't - sets it and returns what was passed in
* @param {K} key
* @param {V} value this value will be written at the key location if the key wasn't found in the Map
* @return {V}
*/
getOrSet(key, value) {
const existing = this.get(key);
if (existing !== undefined) {
return existing;
}
// TODO we can do this more efficiently, no need to hash twice
this.set(key, value);
return value;
}
/**
* Update the entries start of table TAB after removing an entry with index N in the array entries.
* @param {number} bin index of deleted entry
*/
#update_range_for_deleted(bin) {
if (this.__entries_start !== bin) {
return;
}
let start = bin + 1;
let bound = this.__entries_bound;
const entries = this.__entries;
while (start < bound && entries[start].hash === RESERVED_HASH) {
start++;
}
assert.greaterThanOrEqual(bound - start, this.__size, `live entity bounds must span at least number of entries equal to map size(=${this.__size}), instead got start(=${start}), and end(=${bound})`)
this.__entries_start = start;
}
/**
*
* @param {K} key
* @returns {boolean}
*/
delete(key) {
const raw_hash = this.#build_key_hash(key);
let bin_index = this.compute_bin_index(raw_hash);
assert.isFiniteNumber(bin_index, 'hash');
const bins = this.__bins;
const entries = this.__entries;
for (; ;) {
const bin = bins[bin_index];
if (bin > BIN_RESERVED_VALUE_DELETED) {
// bin is occupied
// check if the entry is what we're looking for
const entry_index = bin - ENTRY_BASE;
const entry = entries[entry_index];
if (this.#entry_equality_check(entry, raw_hash, key)) {
// found the right entry
// record entry as dead
this.#deallocate(entry);
// mark slot as removed
bins[bin_index] = BIN_RESERVED_VALUE_DELETED;
this.__size--;
this.#update_range_for_deleted(entry_index);
return true;
}
} else if (bin === BIN_RESERVED_VALUE_EMPTY) {
// bin is empty
return false;
}
// perform secondary hashing
bin_index = generate_next_linear_congruential_index(bin_index, this.__bin_count_mask);
}
}
/**
*
* @param {function(message:string, key:K, value:V)} callback
* @param {*} [thisArg]
* @returns {boolean}
*/
verifyHashes(callback, thisArg) {
let all_hashes_valid = true;
const count = this.__bin_count;
for (let j = 0; j < count; j++) {
const bin = this.__bins[j];
if (bin <= BIN_RESERVED_VALUE_DELETED) {
// unoccupied
continue;
}
/**
* @type {HashMapEntry<K,V>}
*/
const entry = this.__entries[bin - ENTRY_BASE];
//check hash
const raw_hash = this.#build_key_hash(entry.key);
if (entry.hash !== raw_hash) {
callback.call(thisArg, `Hash stored on the entry(=${entry.hash}) is different from the computed key hash(=${raw_hash}).`, entry.key, entry.value)
all_hashes_valid = false;
}
}
return all_hashes_valid;
}
#grow() {
this.#setBinCount(this.__entries_allocated_count * 2);
}
/**
* Rebuild table, useful for when table is resized
*/
rebuild() {
const entries_bound = this.__entries_bound;
const entries = this.__entries;
// reset all bins
const bins = this.__bins;
bins.fill(BIN_RESERVED_VALUE_EMPTY);
let written_entries = 0;
for (
let existing_entry_index = this.__entries_start;
existing_entry_index < entries_bound;
existing_entry_index++
) {
const entry = entries[existing_entry_index];
const hash = entry.hash;
if (hash === RESERVED_HASH) {
// entry is dead
continue;
}
const new_index = written_entries;
written_entries++;
if (new_index !== existing_entry_index) {
// move entries to the new position, compacting holes
const temp = entries[new_index];
entries[new_index] = entries[existing_entry_index];
entries[existing_entry_index] = temp;
}
let bin_index = this.compute_bin_index(hash);
// search for place for the entry
for (; ;) {
const bin = bins[bin_index];
if (bin === BIN_RESERVED_VALUE_EMPTY) {
// empty slot, take it
bins[bin_index] = new_index + ENTRY_BASE;
break;
}
// perform secondary hashing
bin_index = generate_next_linear_congruential_index(bin_index, this.__bin_count_mask);
}
}
assert.equal(written_entries, this.__size, `live entries(=${written_entries}) should match size(=${this.__size})`);
this.__entries_start = 0;
this.__entries_bound = this.__size;
this.__version++;
}
#count_live_entities() {
let count = 0;
for (let i = this.__entries_start; i < this.__entries_bound; i++) {
const entry = this.__entries[i];
if (entry.hash !== RESERVED_HASH) {
count++;
assert.equal(entry.hash, this.#build_key_hash(entry.key));
} else {
assert.isNull(entry.key, 'key');
}
}
return count;
}
forEach(callback, thisArg) {
assert.isFunction(callback, 'callback');
const count = this.__bin_count;
const entries = this.__entries;
const bins = this.__bins;
const start_version = this.__version;
for (let j = 0; j < count; j++) {
assert.equal(start_version, this.__version, 'HashMap modified during traversal');
const bin = bins[j];
if (bin <= BIN_RESERVED_VALUE_DELETED) {
// unoccupied
continue;
}
/**
* @type {HashMapEntry<K,V>}
*/
const entry = entries[bin - ENTRY_BASE];
// Signature based on MDN docs of Map.prototype.forEach()
callback.call(thisArg, entry.value, entry.key, this);
}
}
/**
*
* @param {K} key
* @returns {boolean}
*/
has(key) {
return this.get(key) !== undefined;
}
/**
* Remove all data from the Map
*/
clear() {
// clear out all
const bins = this.__bins;
const count = this.__bin_count;
for (let i = 0; i < count; i++) {
const bin = bins[i];
if (bin !== BIN_RESERVED_VALUE_EMPTY) {
if (bin !== BIN_RESERVED_VALUE_DELETED) {
// occupied, move to deleted
const entry_index = bin - ENTRY_BASE;
this.#deallocate(this.__entries[entry_index]);
}
// mark as empty
bins[i] = BIN_RESERVED_VALUE_EMPTY;
}
}
this.__size = 0;
this.__entries_start = 0;
this.__entries_bound = 0;
}
* [Symbol.iterator]() {
const count = this.__bin_count;
const bins = this.__bins;
const entries = this.__entries;
const start_version = this.__version;
for (let j = 0; j < count; j++) {
assert.equal(start_version, this.__version, 'HashMap modified during traversal');
const bin = bins[j];
if (bin <= BIN_RESERVED_VALUE_DELETED) {
// unoccupied
continue;
}
/**
* @type {HashMapEntry<K,V>}
*/
const entry = entries[bin - ENTRY_BASE];
yield [entry.key, entry.value];
}
}
/**
*
* @returns {Iterator<[K,V]>}
*/
* entries(){
for (const e of this){
yield e;
}
}
/**
*
* @returns {Iterator<V>}
*/
* values() {
for (const [k, v] of this) {
yield v;
}
}
/**
*
* @returns {Iterator<K>}
*/
* keys() {
for (const [k, v] of this) {
yield k;
}
}
}