Last active
October 13, 2022 19:56
-
-
Save intoxopox/f6b0ae58d7fcaa4f565b8a4e10f5e9f3 to your computer and use it in GitHub Desktop.
FiStMa - Finite State Machine with settable callback stacks for onEnter, onExit, and onInvalidTransition.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//////////////////////////////////////////////////////////////////////////////// | |
// Copyright(C) 2018 David Hamiter | |
// Updated 10/13/2022 | |
//////////////////////////////////////////////////////////////////////////////// | |
'use strict'; | |
export default class FiStMa<T> | |
{ | |
//---------------------------------------------------------------------- | |
// | |
// Properties | |
// | |
//---------------------------------------------------------------------- | |
/** The states object (usually an Enum). */ | |
public readonly states: T; | |
public readonly _stateMap: T[keyof T][] = [] | |
private _previousState: T[keyof T] | undefined; | |
public get previousState(): T[keyof T] | undefined { | |
return this._previousState; | |
} | |
private _currentState: T[keyof T]; | |
/** The state we are currently on. If setting, runs goTo method. */ | |
public get currentState(): T[keyof T] { | |
return this._currentState; | |
} | |
public set currentState(value: T[keyof T]) { | |
this.goTo(value); | |
} | |
/** Whether to allow a state to transition to itself. */ | |
public allowSelfTransition: boolean = true; | |
/** Object map of allowed transitions from 'key' state to 'toStates[]' */ | |
private _allowedTransitions: { [key: string]: T[keyof T][] } = Object.create(null); | |
/** Object map of callbacks to run when entering the 'key' state. */ | |
private _onEnterCallbacks: { [key: string]: { (from: T[keyof T]): void; }[] } = Object.create(null); | |
/** Object map of callbacks to run when exiting the 'key' state. */ | |
private _onExitCallbacks: { [key: string]: { (from: T[keyof T]): void; }[] } = Object.create(null); | |
private _invalidTransitionCallback?: (from?: T[keyof T], to?: T[keyof T]) => any; | |
//---------------------------------------------------------------------- | |
// | |
// Constructor | |
// | |
//---------------------------------------------------------------------- | |
/** | |
* Create new instance, passing possible states (usually an enum), currentState, | |
* and whether to allow transitions from and to the same state. | |
* @param {T} states | |
* @param {T[keyof T]} currentState | |
* @param {boolean} allowSelfTransition | |
*/ | |
constructor(states: T, currentState: T[keyof T], allowSelfTransition: boolean = true) { | |
// TODO: Refactor to map states to key string collection | |
this.states = states; | |
for (let prop in this.states){ | |
this._stateMap.push(this.states[prop]); | |
} | |
this._currentState = currentState; | |
this.allowSelfTransition = allowSelfTransition; | |
} | |
//---------------------------------------------------------------------- | |
// | |
// Event Handlers | |
// | |
//---------------------------------------------------------------------- | |
/** | |
* Add callback to run when entering the given state, or any state if '*'. | |
* @param state | |
* @param callback | |
*/ | |
public addOnEnter(state: T[keyof T] | "*", callback: (fromState?: T[keyof T]) => any): FiStMa<T> { | |
const key = String(state); | |
if (!this._onEnterCallbacks[key]) this._onEnterCallbacks[key] = []; | |
this._onEnterCallbacks[key].push(callback); | |
return this; | |
} | |
/** | |
* Remove specified onEnter callback for the given state, or any state if '*'. | |
* @param state | |
* @param callback | |
*/ | |
public removeOnEnter(state: T[keyof T] | "*", callback: (fromState?: T[keyof T]) => any): FiStMa<T> { | |
const key = String(state); | |
if (!this._onEnterCallbacks[key]) this._onEnterCallbacks[key] = []; | |
const index = this._onEnterCallbacks[key].indexOf(callback); | |
if (index > -1) { | |
this._onEnterCallbacks[key].splice(index, 1); | |
} else { | |
console.warn("state", state, "has no such callback."); | |
} | |
return this; | |
} | |
/** | |
* Add callback to run when exiting the given state, or any state if '*'. | |
* @param state | |
* @param callback | |
*/ | |
public addOnExit(state: T[keyof T] | "*", callback: (toState?: T[keyof T]) => any): FiStMa<T> { | |
const key = String(state); | |
if (!this._onExitCallbacks[key]) this._onExitCallbacks[key] = []; | |
this._onExitCallbacks[key].push(callback); | |
return this; | |
} | |
/** | |
* Remove specified onExit callback for the given state, or any state if '*'. | |
* @param state | |
* @param callback | |
*/ | |
public removeOnExit(state: T[keyof T] | "*", callback: (fromState?: T[keyof T]) => any): FiStMa<T> { | |
const key = String(state); | |
if (!this._onExitCallbacks[key]) this._onExitCallbacks[key] = []; | |
const index = this._onExitCallbacks[key].indexOf(callback); | |
if (index > -1) { | |
this._onExitCallbacks[key].splice(index, 1); | |
} else { | |
console.warn("state", state, "has no such callback."); | |
} | |
return this; | |
} | |
/** | |
* Callback which will fire when an invalid transition is attempted. | |
* @param callback | |
*/ | |
public onInvalidTransition(callback: (from?: T[keyof T], to?: T[keyof T]) => any): FiStMa<T> { | |
if (!this._invalidTransitionCallback || this._invalidTransitionCallback != callback) this._invalidTransitionCallback = callback; | |
return this; | |
} | |
//---------------------------------------------------------------------- | |
// | |
// Methods | |
// | |
//---------------------------------------------------------------------- | |
/** | |
* Returns whether we are currently in the given state. | |
* @param state | |
*/ | |
public inState(state: T[keyof T]): boolean { | |
return this._currentState === state; | |
} | |
/** | |
* Returns if the current state is allowed to transition to the given state. | |
* @param toState | |
*/ | |
public canGoTo(toState: T[keyof T]): boolean { | |
return this._isValidTransition(this._currentState, toState); | |
} | |
/** | |
* Attempt to transition to the given state and return if the transition was successful. | |
* Runs any associated onExit and onEnter callbacks if transition is allowed. | |
* @param toState | |
*/ | |
public goTo(toState: T[keyof T]): boolean { | |
const success = this._isValidTransition(this._currentState, toState); | |
if (success) { | |
const fromState = this._currentState; | |
// run all onExit callbacks tied to current state before changing states | |
let key = String(fromState); | |
if (!this._onExitCallbacks[key]) this._onExitCallbacks[key] = []; | |
this._onExitCallbacks[key].forEach(callBack => { | |
if (callBack) callBack.call(this, toState); | |
}); | |
// run all onExit callbacks tied to '*' state before changing states | |
key = "*"; | |
if (!this._onExitCallbacks[key]) this._onExitCallbacks[key] = []; | |
this._onExitCallbacks[key].forEach(callBack => { | |
if (callBack) callBack.call(this, toState); | |
}); | |
// store last state | |
this._previousState = this._currentState; | |
// set state | |
this._currentState = toState; | |
// run all onEnter callbacks tied to state we just entered | |
key = String(toState); | |
if (!this._onEnterCallbacks[key]) this._onEnterCallbacks[key] = []; | |
this._onEnterCallbacks[key].forEach(callBack => { | |
//console.log("key state:", key, "callback:", callBack); | |
if (callBack) callBack.call(this, fromState); | |
}); | |
// run all onEnter callbacks tied to '*' state before changing states | |
key = "*"; | |
if (!this._onEnterCallbacks[key]) this._onEnterCallbacks[key] = []; | |
this._onEnterCallbacks[key].forEach(callBack => { | |
//console.log("key state:", key, "callback:", callBack); | |
if (callBack) callBack.call(this, fromState); | |
}); | |
} else { | |
console.warn("transition not allowed", this._currentState, toState); | |
if (this._invalidTransitionCallback) this._invalidTransitionCallback(this._currentState, toState); | |
} | |
return success; | |
} | |
/** | |
* Attempt to transition to next state in the state stack, according to the order of the states initially passed. | |
*/ | |
public next(): void { | |
this.goTo(this._stateMap[this._stateMap.indexOf(this.currentState) + 1]); | |
} | |
/** | |
* get next state | |
*/ | |
public getNext(): T[keyof T] | false { | |
return this._stateMap[this._stateMap.indexOf(this.currentState) + 1] != undefined ? this._stateMap[this._stateMap.indexOf(this.currentState) + 1] : false; | |
} | |
/** | |
* Attempt to transition to previous state in the state stack, according to the order of the states initially passed. | |
*/ | |
public previous(): void { | |
this.goTo(this._stateMap[this._stateMap.indexOf(this.currentState) - 1]); | |
} | |
/** | |
* get previous state | |
*/ | |
public getPrevious(): T[keyof T] | false { | |
return this._stateMap[this._stateMap.indexOf(this.currentState) - 1] != undefined ? this._stateMap[this._stateMap.indexOf(this.currentState) - 1] : false; | |
} | |
/** | |
* get previous state | |
*/ | |
public getFirst(): T[keyof T] { | |
return this._stateMap[0]; | |
} | |
/** | |
* get previous state | |
*/ | |
public getLast(): T[keyof T] { | |
return this._stateMap[this._stateMap.length - 1] | |
} | |
/** | |
* Adds allowed transition from 'fromState' to any of the 'toStates'. | |
* Automatically removes duplicate toStates. | |
* If toStates is omitted, every state transition is added. | |
* @param fromState | |
* @param toStates | |
*/ | |
public addTransition(fromState: T[keyof T], ...toStates: T[keyof T][]): void { | |
const key = String(fromState); | |
if (!this._allowedTransitions[key]) this._allowedTransitions[key] = []; | |
if (!toStates.length) { | |
toStates = Object.keys(this.states).map(el => this.states[<keyof T>el]); | |
// if we don't allow transitions to self, remove fromState from toStates... | |
if (!this.allowSelfTransition) { | |
const selfIndex = toStates.indexOf(fromState); | |
toStates.splice(selfIndex, 1); | |
} | |
} | |
this._allowedTransitions[key] = [ ...new Set(this._allowedTransitions[key].concat(toStates)) ]; | |
} | |
/** | |
* Removes allowed transition from 'fromState' to any of the 'toStates'. | |
* If toStates is omitted, every state transition is removed. | |
* @param fromState | |
* @param toStates | |
*/ | |
public removeTransition(fromState: T[keyof T], ...toStates: T[keyof T][]): void { | |
const key = String(fromState); | |
if (!this._allowedTransitions[key]) this._allowedTransitions[key] = []; | |
this._allowedTransitions[key] = this._allowedTransitions[key].filter((el) => toStates.indexOf(el) == -1); | |
} | |
/** | |
* Returns if the fromState is allowed to transition to the toState. | |
* @param fromState | |
* @param toState | |
*/ | |
private _isValidTransition(fromState: T[keyof T], toState: T[keyof T]): boolean { | |
const key = String(fromState); | |
if (!this._allowedTransitions[key]) this._allowedTransitions[key] = []; | |
const indexOf = this._allowedTransitions[key].indexOf(toState); | |
if (this.allowSelfTransition && fromState === toState) return true; | |
return indexOf > -1; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment