UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

615 lines (462 loc) • 17 kB
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; } }