@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
506 lines (392 loc) • 12.8 kB
JavaScript
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
}
}
}