UNPKG

oosmos

Version:

A Hierarchical State Machine Class

388 lines (300 loc) 11.9 kB
/*\ |*| The MIT License (MIT) |*| |*| Copyright (c) 2016 Mark J Glenn and OOSMOS, LLC |*| |*| Permission is hereby granted, free of charge, to any person obtaining a copy |*| of this software and associated documentation files (the "Software"), to deal |*| in the Software without restriction, including without limitation the rights |*| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |*| copies of the Software, and to permit persons to whom the Software is |*| furnished to do so, subject to the following conditions: |*| |*| The above copyright notice and this permission notice shall be included in all |*| copies or substantial portions of the Software. |*| |*| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |*| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |*| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |*| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |*| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |*| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |*| SOFTWARE. \*/ export interface iState { ENTER?: () => void; EXIT?: () => void; TIMEOUT?: () => void; COMPOSITE?: iComposite | (() => iComposite); [EventString: string]: (() => void) /* Use 'any' only to avoid assignment error. */ | any; // Private DOTPATH?: string; } export interface iComposite { DEFAULT?: string; [StateName: string]: iState | (() => iState) /* Use 'any' only to avoid assignment error. */ | any; } export class StateMachine { private m_ROOT: iState; private m_State: iState; private m_Timeouts: { [StateName: string]: number } = {}; private m_Interval: number; private m_EventSourceState: iState; private m_DotPath2State: {[DotStateName: string]: iState} = {}; private m_DebugMode: boolean = false; private m_InBrowser: boolean = typeof(window) !== 'undefined'; private m_DebugID: string; private m_LinesOut: number = 0; private m_MaxLinesOut: number; private m_ScrollIntoView: boolean; constructor(Composite: iComposite) { this.m_ROOT = { COMPOSITE: Composite }; } private InstrumentStateMachine() { let StateStack: string[] = []; function InstrumentComposite(Composite: iComposite) { let StateName: string; for (StateName in Composite) { if (StateName === 'DEFAULT') { continue; } if (typeof(Composite[StateName]) === 'function') { Composite[StateName] = (<() => iComposite> Composite[StateName])(); } InstrumentState.call(this, Composite[StateName], StateName); } // // If there is only one state in the composite, set the // DEFAULT so the user doesn't have to. // if (Object.keys(Composite).length === 1) { Composite.DEFAULT = Object.keys(Composite)[0]; } else { if (!Composite.DEFAULT) { this.Alert('You must specify a DEFAULT if there are more than one state in the composite.'); } } } function InstrumentState(State: iState, StateName: string) { if (State.ENTER && typeof(State.ENTER) !== 'function') { this.Alert('ENTER must be a function.'); } if (State.EXIT && typeof(State.EXIT) !== 'function') { this.Alert('EXIT must be a function.'); } if (typeof(State.COMPOSITE) === 'function') { State.COMPOSITE = (<() => iComposite> State.COMPOSITE)(); } StateStack.push(StateName); State.DOTPATH = StateStack.join('.'); this.m_DotPath2State[State.DOTPATH] = State; if (State.COMPOSITE) { InstrumentComposite.call(this, <iComposite> State.COMPOSITE); } StateStack.pop(); } InstrumentState.call(this, this.m_ROOT, 'ROOT'); }; private StripROOT(StateName: string) { return StateName.replace('ROOT.', ''); } private EnterDefaultStates(Composite: iComposite) { this.m_State = Composite[Composite.DEFAULT]; this.DebugPrint('==> ' + this.StripROOT(this.m_State.DOTPATH)); if (this.m_State.ENTER) { this.m_State.ENTER.call(this); } if (this.m_State.COMPOSITE) { this.EnterDefaultStates.call(this, this.m_State.COMPOSITE); } } private CalculateLCA(StringA: string, StringB: string): string { const A = StringA.split('.'); const B = StringB.split('.'); const Iterations = Math.min(A.length, B.length); let Return: string[] = []; for (let I = 0; I < Iterations && A[I] === B[I]; I++) { Return.push(A[I]); } return Return.join('.'); } public Transition(To: string, ...Args: any[]) { if (this.m_EventSourceState === undefined) { this.m_EventSourceState = this.m_State; } To = 'ROOT.' + To; this.DebugPrint('TRANSITION: ' + this.StripROOT(this.m_EventSourceState.DOTPATH) + ' -> ' + this.StripROOT(To)); let LCA = this.CalculateLCA(this.m_EventSourceState.DOTPATH, To); // // Self-transition is a special case. // if (To === LCA) { let A = LCA.split('.'); A.splice(-1, 1); // Remove last element, in place. LCA = A.join('.'); } let ArgArray = Array.prototype.splice.call(arguments, 1); function EnterStates(FromState: string, ToState: string) { if (FromState === ToState) { return; } const FromArray = FromState.split('.'); const ToSuffix = ToState.replace(FromState + '.', ''); const ToArray = ToSuffix.split('.'); let StatePath: string; do { FromArray.push(ToArray.shift()); StatePath = FromArray.join('.'); this.m_State = this.m_DotPath2State[StatePath]; this.DebugPrint('--> ' + this.StripROOT(StatePath)); if (this.m_State.ENTER) { this.m_State.ENTER.apply(this, ArgArray); } } while (StatePath !== ToState); } function ExitStates(ToState: string, FromState: string) { let FromArray = FromState.split('.'); while (ToState !== FromState) { this.m_State = this.m_DotPath2State[FromState]; this.DebugPrint(' ' + this.StripROOT(FromState) + '-->'); if (this.m_State.EXIT) { this.m_State.EXIT.call(this); } if (this.m_Timeouts[this.m_State.DOTPATH]) { this.DebugPrint('Delete Timeout: ' + this.m_State.DOTPATH + ' ' + this.m_Timeouts[this.m_State.DOTPATH]); delete this.m_Timeouts[this.m_State.DOTPATH]; } FromArray.splice(-1, 1); // Remove last item, in place. FromState = FromArray.join('.'); } } ExitStates.call(this, LCA, this.m_State.DOTPATH); EnterStates.call(this, LCA, To); if (this.m_DotPath2State[this.m_State.DOTPATH].COMPOSITE) { this.EnterDefaultStates.call(this, this.m_DotPath2State[this.m_State.DOTPATH].COMPOSITE); } this.m_EventSourceState = undefined; } public Start(): StateMachine { this.InstrumentStateMachine(); this.EnterDefaultStates.call(this, this.m_ROOT.COMPOSITE); return this; } public Restart() { this.m_State = {}; this.m_Timeouts = {}; this.m_Interval = undefined; this.m_EventSourceState = {}; this.m_DotPath2State = {}; if (this.m_InBrowser) { document.getElementById(this.m_DebugID).innerHTML = ''; this.m_LinesOut = 0; } this.Start(); } public IsIn(StateDotPath: string) { StateDotPath = 'ROOT.' + StateDotPath; if (StateDotPath === this.m_State.DOTPATH) { return true; } const Beginning = StateDotPath + '.'; return this.m_State.DOTPATH.substr(0, Beginning.length) === Beginning; } public Event(EventString: string, ...Args: any[]): void { const CandidateStatePath = this.m_State.DOTPATH.split('.'); while (CandidateStatePath.length > 0) { const CandidateStateDotPath = CandidateStatePath.join('.'); const CandidateState = this.m_DotPath2State[CandidateStateDotPath]; if (EventString in CandidateState) { this.DebugPrint('EVENT: ' + EventString + ' sent to ' + this.StripROOT(this.m_State.DOTPATH)); const EventFunc = <() => void> CandidateState[EventString]; if (EventFunc) { const ArgArray = Array.prototype.splice.call(arguments, 1); this.m_EventSourceState = CandidateState; EventFunc.apply(this, ArgArray); this.m_EventSourceState = undefined; } return; } CandidateStatePath.splice(-1, 1); // Remove last element } this.DebugPrint('EVENT: ' + EventString + '. No handler from ' + this.StripROOT(this.m_State.DOTPATH)); } public SetTimeoutSeconds(TimeoutSeconds: number) { this.m_Timeouts[this.m_State.DOTPATH] = TimeoutSeconds; if (this.m_Interval === undefined) { let IntervalTick = () => { for (const StateDotPath in this.m_Timeouts) { this.m_Timeouts[StateDotPath] -= 1; if (this.m_Timeouts[StateDotPath] <= 0) { const State = this.m_DotPath2State[StateDotPath]; this.m_EventSourceState = this.m_State; this.DebugPrint('Delete Timeout: ' + this.m_State.DOTPATH + ' ' + this.m_Timeouts[StateDotPath]); delete this.m_Timeouts[StateDotPath]; if (State.TIMEOUT) { State.TIMEOUT.call(this); } } } }; this.m_Interval = setInterval(IntervalTick, 1000); } this.DebugPrint('SetTimeoutSeconds:' + this.m_State.DOTPATH + ' ' + TimeoutSeconds); } public DebugPrint(Message: string) { if (this.m_DebugMode) { this.Print(Message); } } public SetDebug(DebugMode: boolean, DebugID?: string, MaxLinesOut?: number, ScrollIntoView?: boolean) { if (typeof(DebugMode) !== 'boolean') { this.Alert('First argument of SetDebug must be a boolean value. Defaulting to false.'); DebugMode = false; } this.m_DebugMode = DebugMode; if (this.m_InBrowser) { if (DebugID === undefined) { this.Alert('DebugID must be set to the ID of a <div> element.'); return; } this.m_DebugID = DebugID; this.m_MaxLinesOut = MaxLinesOut || 200; this.m_ScrollIntoView = ScrollIntoView || false; } } public Print(Message: string) { if (!this.m_InBrowser) { console.log(Message); return; } const DebugDIV = document.getElementById(this.m_DebugID); const TextDIV = document.createElement('div'); const Text = document.createTextNode(Message); TextDIV.appendChild(Text); DebugDIV.appendChild(TextDIV); function IsVisible(Element: HTMLElement) { const Rect = Element.getBoundingClientRect(); const ViewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight); return !(Rect.bottom < 0 || Rect.top - ViewHeight >= 0); } if (this.m_ScrollIntoView && IsVisible(DebugDIV)) { TextDIV.scrollIntoView(false); } this.m_LinesOut += 1; if (this.m_LinesOut > this.m_MaxLinesOut) { DebugDIV.removeChild(DebugDIV.childNodes[0]); } } public Assert(Condition: boolean, Message: string) { if (!Condition) { this.Alert(Message || 'Assertion failed'); } } public Alert(Message: string) { if (this.m_InBrowser) { window.alert(Message); } else { console.log(Message); } } }