@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
615 lines (462 loc) • 17 kB
JavaScript
import { assert } from "../../assert.js";
import { makeArrayBuffer } from "../../binary/makeArrayBuffer.js";
import Signal from "../../events/signal/Signal.js";
import { max2 } from "../../math/max2.js";
import { array_copy } from "../array/array_copy.js";
import { array_buffer_copy } from "../array/typed/array_buffer_copy.js";
/**
* How much space to reserve in a newly created table, counted in ROWs
* @type {number}
*/
const DEFAULT_CAPACITY = 8;
/**
* Must be greater than 1
* @type {number}
*/
const ALLOCATION_GROW_FACTOR = 1.5;
/**
* Minimum number of rows to add when growing
* @type {number}
*/
const ALLOCATION_GROW_MINIMUM_STEP = 32;
/**
* Must be less than 1 and greater than 0
* @type {number}
*/
const ALLOCATION_SHRINK_FACTOR = 0.5;
/**
* Compact binary table storage.
* Very efficient in terms of memory usage and allocation.
* Read and write speeds are optimized through code generation.
* You can think of it as an SQL table but only for numeric types.
*
* @copyright Company Named Limited (c) 2025
* @author Alex Goldring
*/
export class RowFirstTable {
/**
*
* @param {RowFirstTableSpec} spec what does the schema look like? How many columns do we have and what are their types?
* @param {boolean} [shared_array] should we use SharedArrayBuffer instead of ArrayBuffer?
* @constructor
*/
constructor(
spec,
shared_array = false
) {
assert.defined(spec, 'spec');
assert.notNull(spec, 'spec');
assert.equal(spec.isRowFirstTableSpec, true, 'spec.isRowFirstTableSpec !== true');
assert.isBoolean(shared_array, 'shared_array');
/**
*
* @type {RowFirstTableSpec}
*/
this.spec = spec;
/**
*
* @type {ArrayBuffer}
*/
this.data = makeArrayBuffer(spec.bytesPerRecord * DEFAULT_CAPACITY, shared_array);
/**
* number of records
* @type {number}
*/
this.length = 0;
/**
* capacity in number of records
* @type {number}
*/
this.capacity = DEFAULT_CAPACITY;
/**
*
* @type {DataView}
*/
this.dataView = new DataView(this.data);
/**
*
* @type {{added: Signal}}
*/
this.on = {
/**
* @readonly
*/
added: new Signal()
};
}
/**
* Useful for deserialization.
* This is an unsafe method, avoid using it if you are not sure.
* NOTE: capacity is set automatically
* NOTE: length is not set automatically, you have to do that yourself
* @param {ArrayBuffer|SharedArrayBuffer} buffer
*/
set array_buffer(buffer) {
this.data = buffer;
this.dataView = new DataView(buffer);
this.capacity = Math.floor(buffer.byteLength / this.spec.bytesPerRecord);
this.length = 0;
}
/**
*
* @returns {number}
*/
hash() {
const byteLength = this.data.byteLength;
const dataView = this.dataView;
let result = byteLength;
const uint32_length = byteLength >> 2;
// to keep hash calculation fast, do a fixed number evenly spaced taps inside the data table
const tap_count = 31;
const step_size = max2(
Math.floor(uint32_length / tap_count),
1
);
const step_count = Math.floor(uint32_length / step_size);
for (let i = 0; i < step_count; i++) {
const address = (i * step_size) << 2;
const uint32 = dataView.getUint32(address);
result = ((result << 5) - result) + uint32;
}
return result;
}
/**
*
* @param {number} rowCount
*/
setCapacity(rowCount) {
assert.isNonNegativeInteger(rowCount, 'rowCount');
if (this.capacity === rowCount) {
// already right size
return;
}
const oldData = this.data;
const bytesPerRecord = this.spec.bytesPerRecord;
const byteSize = rowCount * bytesPerRecord;
try {
// can be either ArrayBuffer or SharedArrayBuffer
const BufferConstructor = this.data.constructor;
this.data = new BufferConstructor(byteSize);
} catch (e) {
throw new Error("failed to create a new array buffer of size: " + byteSize);
}
//check the size of new array
if (this.data.byteLength !== byteSize) {
throw new Error("Generated array was truncated unexpectedly from " + byteSize + " to " + this.data.byteLength);
}
// TODO consider using {@link array_buffer_copy} instead for speed
const newArray = new Uint8Array(this.data);
const oldArray = new Uint8Array(oldData);
const sourceCopyLength = this.length * bytesPerRecord;
try {
newArray.set(oldArray.subarray(0, sourceCopyLength), 0);
} catch (e) {
if (e instanceof RangeError) {
throw new Error("Failed to copy contents of original due to to size violation. OldSize: " + sourceCopyLength + ", NewSize: " + this.data.byteLength);
} else {
throw e;
}
}
this.capacity = rowCount;
this.dataView = new DataView(this.data, 0);
}
/**
* Drop excess capacity, setting capacity exactly to the current length
*/
trim() {
this.setCapacity(this.length);
}
/**
*
* @param {number} row_count
*/
resize(row_count) {
assert.isNonNegativeInteger(row_count, 'row_count');
if (this.capacity < row_count) {
//grow
const new_size = Math.max(
Math.ceil(row_count * ALLOCATION_GROW_FACTOR),
row_count + ALLOCATION_GROW_MINIMUM_STEP
);
this.setCapacity(new_size);
} else if (this.capacity * ALLOCATION_SHRINK_FACTOR > row_count) {
//shrink
this.setCapacity(row_count);
}
}
/**
*
* @param {number} row_index
* @param {number} column_index
* @param {number} value
*/
writeCellValue(row_index, column_index, value) {
const spec = this.spec;
assert.lessThan(column_index, spec.getColumnCount(), 'overflow');
assert.isNonNegativeInteger(column_index, 'column_index');
assert.isNonNegativeInteger(row_index, 'row_index');
const bytesPerRecord = spec.bytesPerRecord;
const rowAddress = row_index * bytesPerRecord;
const cellWriters = spec.cellWriters;
const cellWriter = cellWriters[column_index];
cellWriter(this.dataView, rowAddress, value);
}
/**
* read a single cell value from the table
* @param {number} row_index
* @param {number} column_index
* @returns {number}
*/
readCellValue(row_index, column_index) {
const spec = this.spec;
assert.lessThan(column_index, spec.getColumnCount(), 'overflow');
assert.isNonNegativeInteger(column_index, 'column_index');
assert.isNonNegativeInteger(row_index, 'row_index');
const bytesPerRecord = spec.bytesPerRecord;
const rowAddress = row_index * bytesPerRecord;
const cellReaders = spec.cellReaders;
const cellReader = cellReaders[column_index];
return cellReader(this.dataView, rowAddress);
}
/**
* Remove rows from the table
* @param {number} index starting row
* @param {number} row_count number of rows to be removed
*/
removeRows(index, row_count) {
//validate presence to requested rows
assert.lessThanOrEqual(index + row_count, this.length, 'underflow');
const data = this.data;
const array = new Uint8Array(data);
const bytesPerRecord = this.spec.bytesPerRecord;
//shift tail of the table forward
const target = index * bytesPerRecord;
const start = target + row_count * bytesPerRecord;
const end = this.length * bytesPerRecord;
array.copyWithin(target, start, end);
//adjust new length
this.length -= row_count;
//resize table
this.resize(this.length);
}
/**
* Insert a number of blank rows at the given offset
* Table becomes larger as a result
* NOTE: doesn't send {@link on.added} signal
* @param {number} index
* @param {number} row_count
*/
insertRows(index, row_count) {
assert.isNonNegativeInteger(index, 'index');
assert.isNonNegativeInteger(row_count, 'row_count');
const future_length = this.length + row_count;
this.resize(future_length);
const data = this.data;
const bytesPerRecord = this.spec.bytesPerRecord;
const array = new Uint8Array(data);
//shift tail of the table forward
const target = (index + row_count) * bytesPerRecord;
const end = future_length * bytesPerRecord;
const start = index * bytesPerRecord;
array.copyWithin(target, start, end);
this.length = future_length;
}
/**
* Created a new row at the end of the table, does not dispatch {@link on.added} signal
* Values are undefined, typically it will be 0s, but if data was previously written in that memory region - that data will be retained.
* Make sure to clear the row or write to it before reading it
* @return {number} index of the created row
*/
createEmptyRow() {
const newRowCount = this.length + 1;
this.resize(newRowCount);
const rowIndex = this.length;
this.length = newRowCount;
return rowIndex;
}
/**
*
* @param {Array.<number>} values
* @returns {number} index of newly added row
*/
addRow(values) {
assert.isArrayLike(values, 'values');
const rowIndex = this.createEmptyRow();
this.spec.writeRowMethod(this.dataView, this.spec.bytesPerRecord * rowIndex, values);
this.on.added.send2(rowIndex, values);
return rowIndex;
}
/**
* @deprecated Use {@link addRow} and {@link writeRow} instead
* @param {number} count number of rows to be added
* @param {function(row_index:number, row:Array.<number>):*} valueSupplier supplier of row values, called with row index and an empty row to be filled
*/
addRows(count, valueSupplier) {
throw new Error('deprecated, use .addRow and .writeRow instead');
}
/**
* Copy a single row, value in the source row is unaffected
* @param {number} source
* @param {number} target
*/
copyRow(source, target) {
assert.isNonNegativeInteger(source, 'source');
assert.isNonNegativeInteger(target, 'target');
if (source === target) {
// no operation
return;
}
// figure out addresses
const bytes_per_record = this.spec.bytesPerRecord;
const source_address = source * bytes_per_record;
const target_address = target * bytes_per_record;
const data_view = this.dataView;
for (let i = 0; i < bytes_per_record; i++) {
const v = data_view.getUint8(source_address + i);
data_view.setUint8(target_address + i, v);
}
}
/**
* Read a single row of values from the table
* @param {number} index
* @param {number[]} [result] where row values are to be stored
* @returns {number[]} result
*/
readRow(index, result = []) {
assert.isNonNegativeInteger(index, 'index');
const spec = this.spec;
spec.readRowMethod(this.dataView, spec.bytesPerRecord * index, result);
return result;
}
/**
* Write a single row of values
* @param {number} index
* @param {number[]} record
*/
writeRow(index, record) {
assert.isNonNegativeInteger(index, 'index');
const spec = this.spec;
assert.greaterThanOrEqual(record.length, spec.getColumnCount(), 'input record length is too small');
spec.writeRowMethod(this.dataView, spec.bytesPerRecord * index, record);
}
/**
* Sets memory region of the row to 0s.
* Useful for initializing dirty rows for re-use.
* If you have a specific value in mind - use {@link writeRow} method instead
* NOTE: All numeric types will produce a 0 after this, so every cell of this row will be 0
* @param {number} index
*/
clearRow(index) {
assert.isNonNegativeInteger(index, 'index');
const spec = this.spec;
const bytesPerRecord = spec.bytesPerRecord;
const address = index * bytesPerRecord;
const dataView = this.dataView;
for (let i = 0; i < bytesPerRecord; i++) {
dataView.setUint8(address + i, 0);
}
}
/**
* Reverse order of rows, row-0 will end up at and previously last row will become the first row etc.
*/
reverse_rows() {
const bpr = this.spec.bytesPerRecord;
const copy_buffer = new Uint8Array(bpr);
const buffer = this.data;
const wrapper = new Uint8Array(buffer);
const length = this.length;
if (length <= 1) {
// need at least 2 rows for reversal to make any change
return;
}
const last_row_index = length - 1;
const traversal_limit = last_row_index >>> 1;
for (let i = 0; i <= traversal_limit; i++) {
const address = i * bpr;
array_copy(wrapper, address, copy_buffer, 0, bpr);
const swap_index = last_row_index - i;
const swap_address = swap_index * bpr;
wrapper.copyWithin(address, swap_address, swap_address + bpr)
wrapper.set(copy_buffer, swap_address);
}
}
/**
* clear out all the data and free memory
*/
clear() {
//clear out data
this.length = 0;
this.setCapacity(0);
}
/**
* Utility method. Returns table contents as a 2d array of [row][cell] form.
* Primarily useful for data conversion and debugging.
* @returns {number[][]}
*/
toRowArray() {
const result = [];
for (let i = 0; i < this.length; i++) {
const row = this.readRow(i);
result.push(row);
}
return result;
}
/**
* Print the table to console.
* Useful for debugging.
*/
printToConsole() {
const rows = this.toRowArray();
console.table(rows);
}
/**
* Copy data from another table. Specs must match.
* NOTE: does not dispatch {@link onAdded} signal
* @param {RowFirstTable} other
*/
copy(other) {
// check that the spec is equivalent
if (!this.spec.equals(other.spec)) {
throw new Error('Different table specs');
}
const record_count = other.length;
this.resize(record_count);
this.length = other.length;
// copy data
const data_byte_count = other.spec.bytesPerRecord * record_count;
array_buffer_copy(other.data, 0, this.data, 0, data_byte_count);
}
/**
* @param {RowFirstTable} other
* @returns {boolean}
*/
equals(other) {
if (other === this) {
// shortcut
return true;
}
if (other === undefined || other === null) {
// this should not generally not happen
return false;
}
if (!this.spec.equals(other.spec)) {
return false;
}
const this_length = this.length;
if (this_length !== other.length) {
return false;
}
const total_bytes = this_length * this.spec.bytesPerRecord;
// TODO can use `is_array_buffer_equals` to speed this up
const this_data_view = this.dataView;
const other_data_view = other.dataView;
for (let i = 0; i < total_bytes; i++) {
const a = this_data_view.getUint8(i);
const b = other_data_view.getUint8(i);
if (a !== b) {
return false;
}
}
return true;
}
}