Skip to content

Commit

Permalink
Undo/redo - logic working
Browse files Browse the repository at this point in the history
  • Loading branch information
xpdota committed Sep 5, 2024
1 parent 7626bf6 commit 82252f9
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 5 deletions.
163 changes: 159 additions & 4 deletions packages/core/src/gear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SPECIAL_SUB_STATS
} from "@xivgear/xivmath/xivconstants";
import {
cloneEquipmentSet,
ComputedSetStats,
EquipmentSet,
EquippedItem,
Expand All @@ -26,12 +27,15 @@ import {
GearSetResult,
Materia,
MateriaAutoFillController,
MateriaAutoFillPrio, MateriaMemoryExport,
MateriaAutoFillPrio,
MateriaMemoryExport,
NO_SYNC_STATS,
RawStatKey,
RawStats, RelicStatMemoryExport,
RawStats,
RelicStatMemoryExport,
RelicStats,
SetDisplaySettingsExport, SlotMateriaMemoryExport,
SetDisplaySettingsExport,
SlotMateriaMemoryExport,
XivCombatItem
} from "@xivgear/xivmath/geartypes";
import {Inactivitytimer} from "./util/inactivitytimer";
Expand Down Expand Up @@ -206,6 +210,19 @@ export function previewItemStatDetail(item: GearItem, stat: RawStatKey): ItemSin
}
}

// GearSetCheckpoint is the actual data
type GearSetCheckpoint = {
equipment: EquipmentSet;
food: FoodItem | undefined;
}
// GearSetCheckpointNode establishes a doubly-linked list of checkpoints.
// This allows us to easily remove the 'redo' tree if you undo and then make a change.
type GearSetCheckpointNode = {
value: GearSetCheckpoint;
prev: GearSetCheckpointNode | null;
next: GearSetCheckpointNode | null;
}

/**
* Class representing equipped gear, food, and other overrides.
*/
Expand All @@ -219,14 +236,18 @@ export class CharacterGearSet {
private _lastResult: GearSetResult;
private _jobOverride: JobName;
private _raceOverride: RaceName;
private _food: FoodItem;
private _food: FoodItem | undefined;
private readonly _sheet: GearPlanSheet;
private readonly refresher = new Inactivitytimer(0, () => {
this._notifyListeners();
});
readonly relicStatMemory: RelicStatMemory = new RelicStatMemory();
readonly materiaMemory: MateriaMemory = new MateriaMemory();
readonly displaySettings: SetDisplaySettings = new SetDisplaySettings();
currentCheckpoint: GearSetCheckpointNode;
checkpointEnabled: boolean = false;
private _reverting: boolean = false;
private _undoHook: () => void = () => null;
isSeparator: boolean = false;

constructor(sheet: GearPlanSheet) {
Expand Down Expand Up @@ -366,6 +387,9 @@ export class CharacterGearSet {
if (this.listeners.length > 0) {
this.refresher.ping();
}
if (this.checkpointEnabled) {
this.recordCheckpoint();
}
}

private _notifyListeners() {
Expand Down Expand Up @@ -734,6 +758,137 @@ export class CharacterGearSet {
setSlotCollapsed(slotId: EquipSlotKey, val: boolean) {
this.displaySettings.setSlotHidden(slotId, val);
}

/*
The way the undo/redo works is this:
You must first call startCheckpoint(callback) with a callback function that is notified when a roll back/forward
happens. This goes above and beyond the usual listener mechanism, since it should ideally refresh potentially
the entire sheet UI.
There is a doubly-linked list of undo states, where currentCheckpoint is a pointer to some node in this list.
Typically this points to the most recent, but if you have undone anything, it will point to somewhere else in the list.
When a checkpoint is requested, use checkpointTimer to debounce requests.
recordCheckpointInt() does the actual recording.
When you roll back (or forward) to a checkpoint, notify listeners, and the callback.
In addition, while performing a roll, _reverting is temporarily set to true, so that it doesn't try to checkpoint
an undo/redo itself.
*/
readonly checkpointTimer = new Inactivitytimer(500, () => {
this.recordCheckpointInt();
});

private recordCheckpointInt() {
if (!this.checkpointEnabled || this._reverting) {
return
}
const checkpoint: GearSetCheckpoint = {
equipment: cloneEquipmentSet(this.equipment),
food: this._food
};
const prev = this.currentCheckpoint;
// Initial checkpoint
if (prev === undefined) {
this.currentCheckpoint = {
value: checkpoint,
prev: null,
next: null
}
}
// There was a previous checkpoint
else {
const newNode: GearSetCheckpointNode = {
value: checkpoint,
prev: prev,
next: null
};
// Insert into the chain, replacing any previous redo history
prev.next = newNode;
this.currentCheckpoint = newNode;
}
console.log("Recorded checkpoint");

}

/**
* Request a checkpoint be recorded.
*/
recordCheckpoint() {
if (!this.checkpointEnabled || this._reverting) {
return
}
this.checkpointTimer.ping();
}

/**
* Initialize the undo/checkpoint mechanism.
*
* @param hook A hook which will be called when a roll back/forward happens.
*/
startCheckpoint(hook: () => void) {
this._undoHook = hook;
this.checkpointEnabled = true;
this.recordCheckpoint();
}

/**
* Reset the current state
*
* @param checkpoint
* @private
*/
private revertToCheckpoint(checkpoint: GearSetCheckpoint) {
if (!this.checkpointEnabled) {
return
}
console.log("Reverting");
// This flag causes things to not record more checkpoints in the middle of reverting to a checkpoint
this._reverting = true;
const newEquipment = cloneEquipmentSet(checkpoint.equipment);
Object.assign(this.equipment, newEquipment);
this._food = checkpoint.food;
try {
this.forceRecalc();
this.notifyListeners();
this._undoHook();
}
finally {
this._reverting = false;
}
}

/**
* Perform an undo
*/
undo(): boolean {
const prev = this.currentCheckpoint?.prev;
if (prev) {
this.revertToCheckpoint(prev.value);
this.currentCheckpoint = prev;
return true;
}
else {
return false;
}
}

redo(): boolean {
const next = this.currentCheckpoint?.next;
if (next) {
this.revertToCheckpoint(next.value);
this.currentCheckpoint = next;
return true;
}
else {
return false;
}
}

canUndo(): boolean {
return this.currentCheckpoint?.prev?.value !== undefined;
}

canRedo(): boolean {
return this.currentCheckpoint?.next?.value !== undefined;
}
}

export function applyStatCaps(stats: RawStats, statCaps: { [K in RawStatKey]?: number }) {
Expand Down
9 changes: 8 additions & 1 deletion packages/frontend/src/scripts/components/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,14 @@ export class GearPlanSheetGui extends GearPlanSheet {
if (select && this._gearPlanTable) {
this._gearPlanTable.selectGearSet(gearSet);
}
gearSet.startCheckpoint(() => this.refreshGearEditor(gearSet));
}

refreshGearEditor(set: CharacterGearSet) {
if (this._editorItem === set) {
this.resetEditorArea();
// this.refreshToolbar();
}
}

refreshToolbar() {
Expand Down Expand Up @@ -1816,7 +1824,6 @@ export class GearPlanSheetGui extends GearPlanSheet {
super.sheetName = name;
setTitle(this._sheetName);
}

}

export class ImportSetsModal extends BaseModal {
Expand Down
25 changes: 25 additions & 0 deletions packages/xivmath/src/geartypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,17 @@ export class EquipmentSet {
Wrist: EquippedItem | null;
RingLeft: EquippedItem | null;
RingRight: EquippedItem | null;

}

export function cloneEquipmentSet(set: EquipmentSet) {
const out = new EquipmentSet();
Object.entries(set).forEach(([slot, equipped]) => {
if (equipped instanceof EquippedItem) {
out[slot] = equipped.clone();
}
});
return out;
}

export interface MateriaSlot {
Expand Down Expand Up @@ -993,5 +1004,19 @@ export class EquippedItem {
this.relicStats = {};
}
}

clone(): EquippedItem {
const out = new EquippedItem(
this.gearItem
);
// Deep clone the materia slots
this.melds.forEach((slot, index) => {
out.melds[index].equippedMateria = slot.equippedMateria
});
if (this.relicStats !== undefined && out.relicStats !== undefined) {
Object.assign(out.relicStats, this.relicStats);
}
return out;
}
}

0 comments on commit 82252f9

Please sign in to comment.