Skip to content

Instantly share code, notes, and snippets.

@intoxopox
Last active October 13, 2022 19:56
Show Gist options
  • Save intoxopox/f6b0ae58d7fcaa4f565b8a4e10f5e9f3 to your computer and use it in GitHub Desktop.
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.
////////////////////////////////////////////////////////////////////////////////
// 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