Source

rez_undo_manager.js

//-----------------------------------------------------------------------------
// Undo Manager
//-----------------------------------------------------------------------------

/**
 * @class RezUndoManager
 * @category Utilities
 * @description Manages undo functionality by tracking changes to game state.
 *
 * The undo manager records changes made during each turn/action and allows
 * reverting to previous states. It tracks:
 * - Attribute changes on game elements
 * - Newly created elements
 * - Removed elements
 * - View state changes
 *
 * Changes are grouped into "change records" that represent a single undoable
 * action. When undo is triggered, all changes in the most recent record are
 * reverted together.
 *
 * The manager maintains a fixed-size history (default 16 records) to limit
 * memory usage. Older records are automatically discarded when the limit
 * is reached.
 *
 * Note: The undo manager automatically ignores changes made during an undo
 * operation to prevent infinite loops.
 *
 * @example
 * // Undo is typically triggered via game events
 * if($game.undoManager.canUndo) {
 *   $game.undoManager.undo();
 * }
 */
class RezUndoManager {
  /** @type {Array<Array>} */
  #changeList;
  /** @type {number} */
  #maxSize;
  /** @type {boolean} */
  #performingUndo;

  /**
   * @function constructor
   * @memberof RezUndoManager
   * @description Creates a new RezUndoManager.
   *
   * @param {number} [maxSize=16] - Maximum number of change records to keep
   */
  constructor(maxSize = 16) {
    this.#maxSize = maxSize;
    this.reset();
  }

  /**
   * Resets the undo manager, clearing all history.
   */
  reset() {
    this.#changeList = [];
    this.#performingUndo = false;
  }

  /**
   * Whether an undo operation is possible.
   *
   * Returns false if currently performing an undo or if history is empty.
   *
   * @type {boolean}
   */
  get canUndo() {
    return !this.#performingUndo && this.#changeList.length > 0;
  }

  /**
   * The number of change records in history.
   * @type {number}
   */
  get historySize() {
    return this.#changeList.length;
  }

  /**
   * The current (most recent) change record, or null if empty.
   * @type {Array|null}
   */
  get curChange() {
    return this.#changeList.length > 0 ? this.#changeList.at(-1) : null;
  }

  /**
   * Whether an undo operation is currently in progress.
   * @type {boolean}
   */
  get performingUndo() {
    return this.#performingUndo;
  }

  /**
   * Starts a new change record.
   *
   * Call this at the beginning of each undoable action. All subsequent
   * recorded changes will be grouped into this record until the next
   * call to startChange().
   *
   * If the history is full, the oldest record is discarded.
   * Does nothing if an undo operation is in progress.
   */
  startChange() {
    // Don't start a new change record if we're in the middle of an undo operation
    if(!this.#performingUndo) {
      if(this.#changeList.length >= this.#maxSize) {
        this.#changeList.shift(); // Remove the first (oldest) element
      }
      this.#changeList.push([]);
    }
  }

  /**
   * Records the creation of a new element.
   *
   * When undone, the element will be unmapped (removed from the game).
   *
   * @param {string} elemId - The ID of the newly created element
   */
  recordNewElement(elemId) {
    if(!this.#performingUndo) {
      this.curChange?.unshift({
        changeType: "newElement",
        elemId: elemId
      });
    }
  }

  /**
   * Discards the most recent change record.
   *
   * Used during undo when the triggering event has already started
   * a new (empty) change record.
   *
   * @private
   */
  #discardChange() {
    this.#changeList.pop();
  }

  /**
   * Records the removal of an element.
   *
   * When undone, the element will be restored to the game.
   *
   * @param {Object} elem - The element being removed
   */
  recordRemoveElement(elem) {
    if(!this.#performingUndo) {
      this.curChange?.unshift({
        changeType: "removeElement",
        elem: elem
      });
    }
  }

  /**
   * Records an attribute change on an element.
   *
   * When undone, the attribute will be restored to its old value.
   *
   * @param {string} elemId - The ID of the element
   * @param {string} attrName - The name of the changed attribute
   * @param {*} oldValue - The previous value of the attribute
   */
  recordAttributeChange(elemId, attrName, oldValue) {
    if(!this.#performingUndo) {
      this.curChange?.unshift({
        changeType: "setAttribute",
        elemId: elemId,
        attrName: attrName,
        oldValue: oldValue
      });
    }
  }

  /**
   * Records a view state change.
   *
   * When undone, the view will be restored to its previous state.
   *
   * @param {RezView} view - A copy of the view state to restore
   */
  recordViewChange(view) {
    if(!this.#performingUndo) {
      this.curChange?.unshift({
        changeType: "view",
        view: view
      });
    }
  }

  /**
   * Undoes the most recent change record.
   *
   * Reverts all changes in the record in reverse order. Sets a flag
   * to prevent recording changes made during the undo.
   *
   * @param {boolean} [manualUndo=false] - If true, doesn't discard the current
   *   change record (used when undo is triggered manually rather than by an event)
   */
  undo(manualUndo = false) {
    if(this.canUndo) {

      // Set flag to prevent recording changes during undo
      this.#performingUndo = true;

      try {
        console.log("RezUndoManager: Starting undo operation");

        if(!manualUndo) {
          this.#discardChange();
        }
        const changes = this.#changeList.pop();

        console.log(`RezUndoManager: Undoing ${changes.length} changes`);
        console.dir(changes);

        // Apply all regular changes
        changes.forEach((change) => {
          if(change.changeType === "newElement") {
            this.#undoNewElement(change);
          } else if(change.changeType === "setAttribute") {
            this.#undoSetAttribute(change);
          } else if(change.changeType === "removeElement") {
            this.#undoRemoveElement(change);
          } else if(change.changeType === "view") {
            this.#undoViewChange(change);
          } else {
            throw new Error(`Unknown change type: ${change.changeType}`);
          }
        });

      } finally {
        // Clear the flag when we're done
        this.#performingUndo = false;
      }
    }
  }

  /**
   * Undoes the creation of a new element by removing it.
   *
   * @param {Object} change - The change record
   * @param {string} change.elemId - The element ID to remove
   * @private
   */
  #undoNewElement({elemId}) {
    $(elemId, true).unmap();
  }

  /**
   * Undoes the removal of an element by restoring it.
   *
   * @param {Object} change - The change record
   * @param {Object} change.elem - The element to restore
   * @private
   */
  #undoRemoveElement({elem}) {
    $game.addGameObject(elem);
  }

  /**
   * Undoes an attribute change by restoring the old value.
   *
   * @param {Object} change - The change record
   * @param {string} change.elemId - The element ID
   * @param {string} change.attrName - The attribute name
   * @param {*} change.oldValue - The value to restore
   * @private
   */
  #undoSetAttribute({elemId, attrName, oldValue}) {
    $(elemId, true).setAttribute(attrName, oldValue);
  }

  /**
   * Undoes a view change by restoring the previous view state.
   *
   * @param {Object} change - The change record
   * @param {RezView} change.view - The view state to restore
   * @private
   */
  #undoViewChange({view}) {
    $game.restoreView(view)
  }
}

window.Rez.RezUndoManager = RezUndoManager;