UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

506 lines (392 loc) • 12.8 kB
import { assert } from "../../assert.js"; import List from "../../collection/list/List.js"; import Signal from "../../events/signal/Signal.js"; import { min2 } from "../../math/min2.js"; import { Mark } from "./Mark.js"; /** * @template C, M * @constructor * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class ActionProcessor { /** * @template C, M * @param {C} context * @constructor */ constructor(context) { /** * * @type {List<Action>} */ this.history = new List(); /** * * @type {C} */ this.context = context; /** * * @type {Array<Mark<M>>} */ this.marks = []; /** * @type {number} */ this.cursor = 0; /** * * @type {number} */ this.historyMemoryUsage = 0; /** * History beyond this limit will be dropped when a new mark is added * @type {number} */ this.historyMarkLimit = 10000; /** * Maximum amount of memory allowed for the history * @type {number} */ this.historyMemoryLimit = 268435456; /** * * @type {Signal<M> | Signal} */ this.onMarkAdded = new Signal(); /** * * @type {Signal<M> | Signal} */ this.onMarkRemoved = new Signal(); /** * * @type {Promise<never>} * @private */ this.__last_promise = Promise.resolve(); } /** * * @returns {number} */ getMarkCount() { return this.marks.length; } /** * * @param {number} index * @returns {M} * @throws {Error} when index is out of range */ getMarkDescription(index) { const mark = this.marks[index]; if (mark === undefined) { throw new Error(`Index ${index} is out of range (mark count = ${this.marks.length})`); } return mark.description; } /** * Remove all of the history */ clear() { // drop all of the history this.dropHistory(Infinity); } /** * Drop number of records from the history * * NOTE valid use-cases for this method from the outside are: 1) memory management and 1) removing access to a set of previous actions * NOTE this method is used internally by {@link #mark} method * * @example if there are 2 marks A at record 9 and B at record 11, and requested limit is 10 - 9 records will be kept as records 10 and 11 belong to the same batch and will be removed together. * @param {number} drop_count maximum number of records to drop */ dropHistory(drop_count) { const recordsToDrop = Math.min(drop_count, this.history.length); if (recordsToDrop <= 0) { return; } //update marks let i = 0; const marks = this.marks; let markCount = marks.length; for (; i < markCount && marks[i].index <= recordsToDrop; i++) { //find the cur-off point for marks to keep } //drop the marks const dropped_marks = marks.splice(0, i); //update mark count markCount = marks.length; //update remaining marks for (i = 0; i < markCount; i++) { const mark = marks[i]; mark.index -= recordsToDrop; } //update cursor this.cursor -= recordsToDrop; // dispatch signal dropped_marks.forEach(this.onMarkRemoved.send1, this.onMarkRemoved); // update history } /** * Insert a mark into the timeline, all actions between marks are grouped logically for UNDO and REDO mechanism * @param {D} description */ async mark(description) { await this.__barrier(); const marks = this.marks; const numMarks = marks.length; //check current cursor, we might need to drop some marks if (numMarks > 0) { const lastMarkIndex = marks[numMarks - 1].index; if (lastMarkIndex >= this.cursor) { //drop marks also let markIndexToKeep; for (markIndexToKeep = numMarks - 1; markIndexToKeep >= 0; markIndexToKeep--) { if (marks[markIndexToKeep].index <= this.cursor) { break; } } const removed_marks = marks.splice(markIndexToKeep, numMarks - markIndexToKeep); // dispatch signal removed_marks.forEach(this.onMarkRemoved.send1, this.onMarkRemoved); } } const mark = new Mark(this.cursor, description); mark.memoryUsage = this.historyMemoryUsage; marks.push(mark); this.onMarkAdded.send1(description); this.cleanupHistory(); } cleanupHistory() { const marks = this.marks; let lastMark = min2(marks.length, this.historyMemoryLimit) - 1; if (this.historyMemoryUsage >= this.historyMemoryLimit) { for (; lastMark > 0 && marks[lastMark].memoryUsage > this.historyMemoryUsage; lastMark--) { } } if (lastMark + 1 < marks.length) { const marksToDrop = lastMark - this.historyMarkLimit; //get first mark to keep const newFirstMark = marks[marksToDrop]; //drop history before first mark's index this.dropHistory(newFirstMark.index); assert.equal(newFirstMark.index, 0, "New First Mark's index must be 0"); } } /** * * @param {M} descriptor * @returns {number[]} * @private */ __find_mark_indices_by_description(descriptor) { const result = []; const marks = this.marks; const mark_count = marks.length; for (let i = 0; i < mark_count; i++) { const mark = marks[i]; if (mark.description === descriptor) { result.push(i); } } return result; } /** * Navigate to a specific mark back or forward * @param {M} mark_descriptor specific mark description * @returns {boolean} true on success, false otherwise (e.g. mark not found) * @throws {Error} when mark is non-unique */ async navigateTo(mark_descriptor) { await this.__barrier(); const p = this.__unsafe_navigateTo(mark_descriptor); this.__last_promise = p; return await p; } /** * Navigate to a specific mark back or forward * @param {M} mark_descriptor specific mark description * @returns {boolean} true on success, false otherwise (e.g. mark not found) * @throws {Error} when mark is non-unique * @private */ async __unsafe_navigateTo(mark_descriptor) { // find all matching marks const matching_marks = this.__find_mark_indices_by_description(mark_descriptor); if (matching_marks.length === 0) { // not found return false; } if (matching_marks.length > 1) { throw new Error(`Attempting to navigate to a non-unique mark descriptor '${mark_descriptor}'. Mark descriptor must be unique, instead ${matching_marks.length} matches were found`); } const match_index = matching_marks[0]; let target; if (match_index + 1 >= this.marks.length) { // mark is already at the end, go to the end of the history target = this.history.length; } else { target = this.marks[match_index + 1].index; } let temp_cursor = this.cursor; if (target > this.cursor) { while (target > this.cursor) { // navigate forward await this.__unsafe_redo(); if (temp_cursor === this.cursor) { // no cursor change return false; } temp_cursor = this.cursor; } } else if (target < this.cursor) { while (target < this.cursor) { // navigate backwards await this.__unsafe_undo(); if (temp_cursor === this.cursor) { // no cursor change return false; } temp_cursor = this.cursor; } } return true; } async undo() { await this.__barrier(); const p = this.__unsafe_undo(); this.__last_promise = p; await p; } /** * * @returns {Promise<void>} * @private */ async __unsafe_undo() { let markAddress = 0; /** * * @type {Mark} */ let mark = null; //find mark const marks = this.marks; for (let i = marks.length - 1; i >= 0; i--) { mark = marks[i]; markAddress = mark.index; if (markAddress < this.cursor) { break; } } if (this.cursor === markAddress) { //this only happens when cursor sits on the last mark markAddress = 0; } //keep rewinding until we hit a mark while (this.cursor > markAddress && this.cursor > 0) { this.cursor--; const action = this.history.get(this.cursor); await action.revert(this.context); } if (mark !== null) { this.historyMemoryUsage = mark.memoryUsage; } } /** * redo previous sequence of actions on the timeline, up to the next mark */ async redo() { await this.__barrier(); const p = this.__unsafe_redo(); this.__last_promise = p; await p; } /** * * @returns {Promise<void>} * @private */ async __unsafe_redo() { let mark = this.history.length; //find mark const marks = this.marks; for (let i = marks.length - 1; i >= 0; i--) { const m = marks[i]; const mI = m.index; if (mI > this.cursor) { mark = mI; } else { break; } } //keep apply action until we hit the mark while (this.cursor < mark) { const action = this.history.get(this.cursor); await action.apply(this.context); this.historyMemoryUsage += action.computeByteSize(); this.cursor++; } } /** * * @param {Action[]} actions */ async doMany(actions) { await this.__barrier(); const p = new Promise(async (resolve, reject) => { const n = actions.length; for (let i = 0; i < n; i++) { const action = actions[i]; await this.__unsafe_do(action); } resolve(); }); this.__last_promise = p; await p; } /** * Execute a given action and add it to the timeline * @param {Action} action */ async do(action) { await this.__barrier(); const p = this.__unsafe_do(action); this.__last_promise = p; await p; } /** * * @param {Action} action * @returns {Promise<void>} * @private */ async __unsafe_do(action) { await action.apply(this.context); this.historyMemoryUsage += action.computeByteSize(); const history = this.history; //doing action invalidates "future" branch of history, we need to clear all actions beyond cursor if (this.cursor < history.length) { history.crop(0, this.cursor); // TODO clear future marks } history.add(action); this.cursor++; } /** * Await for synchronization barrier * @returns {Promise<void>} * @private */ async __barrier() { try { await this.__last_promise; } catch (e) { // ignore } } }