用 Typescript 写个状态机

有限状态机,是常用的一种编程范式。游戏领域和编译器领域等工作的小伙伴,应该很常用的了。如果不熟悉,那咱们先来看看状态机是什么。

状态机

假设有这样一个需求

在介绍状态机之前,假设有这样一个需求:咱们在开发一款打斗类游戏,游戏里有一个主角,咱们要通过键盘控制主角的行为。主角可以站立、蹲着、跳跃,这些行为的流程如下:

总结一下:

  1. 通过键盘按钮主角的行为;
  2. 主角的行为包括:站立、蹲着、跳跃;
  3. 站立(按S) -> 蹲着;蹲着(按空格) -> 站立;
  4. 站立(按空格) -> 跳跃;跳跃 -> 站立;

很明显,“蹲着” 不能直接到 “跳跃”。

接下来,按照一般的思路,实现这个需求:

class Hero {
    isStand = false;   // 是否是站立
    isKneel = false;   // 是否是蹲着
    isLeap = false;    // 是否在空中

    handleInput(event: any) {
        if (event == 'S') { // 按下 'S' 键
            if (this.isStand) {   // 如果是站立,则蹲下
                this.toKneel();
            }
        } else if (event == 'Space') { // 按下 ‘空格’ 锋
            if (this.isStand) {    // 如果是站立,则跳起
                this.jump();
            } else if (this.isKneel) {  // 如果是蹲着,则站起
                this.standup();
            }
        } else if (event == 'down') { // 下落事件
            if (this.isLeap) {  // 如果在空中,则站起
                this.land();
            }
        }
    }

    toKneel() {
        // ... 蹲下
        this.isStand = false;
        this.isKneel = true;
    }

    jump() {
        // ... 跳起来
        this.isStand = false;
        this.isLeap = true;
    }

    standup() {
        // ... 站起来
        this.isStand = true;
        this.isKneel = false;
    }

    land() {
        // ... 落地
        this.isStand = true;
        this.isLeap = false;
    }
}

上面的代码中,我们使用 isXXX 变量来标识是当前的状态。在输入事件处理方法中,根据 状态event 调用相应的方法。

现在一切都很完美,可以泡杯绿茶好好享受一下了。

但还没喝到五分钟,产品小姐姐跑到你的身边,“为了不使英雄这么单调,我给他加了些需求。包括行走、跑、二段跳、三段跳”,小姐姐看着你甜甜地说道。于是只好将手中杯子放下,敲起了键盘。

现在新增了四个行为:“行走、跑、二段跳、三段跳”

咱们如下实现:

class Hero {
    isStand = false;   // 是否是站立
    isKneel = false;   // 是否是蹲着
    isLeap = false;    // 是否在空中
    isWalking = false; // 是否在行走
    isRunning = false; // 是否在跑
    isLeapTwo = false; // 是否是二段跳
    isLeapThree = false; // 是否是三段跳

    handleInput(event: any) {
        if (event == 'S') {
            if (this.isStand) {
                this.toKneel();
            }
        } else if (event == 'DA' || event == 'DD') { // 按下 A 或 D 键
            if (this.isStand) {
                this.toWalk();  // 如果站立,则行走
            } else if (this.isWalking) {
                this.toRunning();  // 如果正在跑,则行走
            }
        } else if (event == 'UA' || event == 'UD') { // 松开 A 或 D 键
            if (this.isWalking || this.isRunning) {
                this.standup();   // 如果是正在行走 或 跑,则到站立
            }
        }  else if (event == 'Space') {
            if (this.isStand) {
                this.jump();
            } else if (this.isLeap && !this.isLeapTwo && !this.isLeapThree) {
                this.toLeapTwo(); // 如果正在空中,则进入二段跳
            } else if (this.isLeapTwo) {
                this.toLeapThree(); // 如果正在二段跳,则进入三段跳
            } else if (this.isKneel) {
                this.standup();
            }
        } else if (event == 'down') { // 下落事件
            if (this.isLeap || this.isLeapTwo || this.isLeapThree) {
                this.land();
            }
        }
    }
    
    ......
}

代码写到一半,小姐姐又过来了:“要给英雄加上技能,有的技能要在站着时才能放,有的要在空中才能放,还有的技能要续能…”。想想这些需求吧,如果我们继续按照上面的代码那样处理,代码会是一个什么样的状况?无数的状态变量,深得可怕的分支,一大堆的 bug …

用状态机实现这个需求

状态机适合处理这种多状态的需求。可以用 enum 将各状态定义出来,再使用根据这些状态来进行处理:

enum EHeroStatus {
    Stand,
    Kneel,
    Walking,
    Running,
    Leap,
    LeapTwo,
    LeapThree,
}

上面的代码将英雄的状态都用枚举列出来了,现在根据这些状态再来看看处理的代码:

class Hero {
    status: EHeroStatus;

    handleInput(event: any) {
        if (this.status == EHeroStatus.Stand) {
            if (event == 'S') {
                this.toKneel();
            } else if (event == 'DA' || event == 'DD') {
                this.toWalk();
            } else if (event == 'Space') {
                this.jump();
            }
        } else ...
    }
}    

这里将各类状态分开,再处理各类输入的事件。

别看这里改动不大,但成功地将复杂度降低了不少,再每一个状态中,只需简单考虑事件与下一个状态的转换关系即可。当然,这里可以将状态封装成类:

interface HeroState {
    handInput(hero: Hero, input: Input): void;
}

class StandState implements HeroState {
    handInput(hero: Hero, input: Input) {
        if (input == 'S') {
            hero.toState(new KneelState());
        } else if (input == 'DA' || input == 'DD') {
            hero.toState(new WalkState());
        } else if (input == 'Space') {
            hero.toState(new SpaceState());
        }
    }
}

class KneelState implements HeroState ...

class Hero {
    private state: HeroState;

    handleInput(input: Input) {
        this.state.handInput(this, input);
    }
    
    toState(state: HeroState) {
        this.state = state;
    }
}

上面的代码中,Hero 类的 handleInput 方法清晰明了,内部的逻辑已经封装到那些状态类内部了。

以上,就是一个简单的状态机了。

Typescript 实现

Typescript 是一门像极了 Java 的动态语言,可它本质上,还是 Javascript。在写这个状态机库之前,我们先整理一下需求,即想好库应该写成什么样子:

  1. 先定个名字。嗯,就叫 StateMachine 吧;
  2. 抽象状态。上面第二种状态机代码实现中,将每一种状态都抽象成类,这样处理,会将 状态逻辑 完全耦合在一起,难以复用。所以将 状态 抽象出来;
  3. 抽象状态转换时的行为。上面第一种状态机代码实现中,就是将状态定义为枚举,但在其业务逻辑却混在一起,所以需要将这一部分抽象出来;
  4. 状态转换验证。状态间的转换是有一定规则的,比如二段跳状态,其前置状态必定是跳跃状态,所以需要有一个验证机制;
  5. ‘*’ 支持。有时不希望转换被前置条件限制,比如:重置功能。

1. State

状态。在咱们这里,不需要做任何限制,直接以模板参数表示即可。

2. Transition

状态机,主要在于状态之间的转换。首先,我们将状态间的转换抽象出来。把 状态转换 定义为 Transition。转换的数据成员包括:来源状态(即上一个状态)、去向(转换后的状态),再加一个转换触发时的回调,这样就提供给外部进行逻辑处理的机会。

/** 状态转换描述接口 */
interface ITransitionDir {
    /** 来源状态,可以是 1 到 N 状态,'*' 表示任何状态 */
    from: State | State[] | '*';
    /** 转换后的目标状态 */
    to: State;
    /** 转换触发时的回调 */
    onTransition?: (from: State, to: State) => void;
}

为了表示方便,可以导出一个构建函数:

export function BuildTransition(from: State | State[] | '*', to: State, onTransition?: (from: State, to: State) => void): ITransitionDir {
    return {from, to, onTransition};
}

3. 初始化条件

这些条件是需要由用户提供的。基本条件有两个:

  1. 初始状态;
  2. 转换对象。

用代码示例为:

interface STOptions {
    init: State;
    transitions: {
        transName1: ITransitionDir;
        transName2: ITransitionDir;
        ...
    }
}

实际上,这些数据描述了状态机的数据的流向。我们的在此要实现的状态机代码本质上是对这些数据的自驱动。上面的这个接口有一个问题:transitions 对象里的 keys ,即状态转换的名字应该是由用户定义的,如上面的代码直接给出名字,明显不合理。还好 typescript 的模版功能非常强大,它能将某个对象里所有已知的属性名展开:

type IDemo = {
    [P in keyof T]: string;
}

这里的代码意思就是将 T 中的属性依次取出,并且其类型为 string。如:

interface IA {
    a: number;
    b: string;
    c: () => void;
}

let b: IDemo;

此时变量 b 的类型是:

inetface TypeB {
    a: string;
    b: string;
    c: string;
}

利用这个方便的特性,我们再定义一次 STOptions

type ITransitions = {
    [P in keyof T]: ITransitionDir | ITransitionDir[];
}

interface STOptions {
    init: State;
    transitions: ITransitions;
}

4. 定义类

现在可以定义类了,先利用已经的信息将框架定义出来:

export class StateMachine {

    constructor(option: STOptions) {
    }
}

那咱们应该如何来进行状态转换呢?最方便的当然是使用用户自己定义的名字啦,我们可以再使用上面那个套路:

type TransitionCall = {
    [P in keyof T]: () => void;
};

export class StateMachine {
    private _transitions: TransitionCall;
    private _curState: State;
    private _originTransitions: ITransitions;

    constructor(option: STOptions) {
        const {init, transitions} = option;

        this._curState = init;
        
        this.setupTransitions(transitions);
    }
    
    private setupTransitions(transitions: ITransitions) {
        this._originTransitions = transitions;
        this._transitions = {} as any;

        Object.keys(transitions).forEach(k => {
            const key = k as keyof T;

            const value: ITransitionDir | ITransitionDir[] = transitions[key];

            this._transitions[key] = (() => {
                //进行状态转换
                ...
            });
        });
    }


    transition() {
        return this._transitions;
    }
}    

现在可以这么用了:


enum EQiuActionStatus {
    None = 'None',
    PreAction = 'PreAction',
    MyTurn = 'MyTurn',
    Standup = 'Standup',
};

const option = {
    init: EQiuActionStatus.None,
    transitions: {
        step: [
            BuildTransition(EQiuActionStatus.None, EQiuActionStatus.PreAction),
            BuildTransition(EQiuActionStatus.PreAction, EQiuActionStatus.MyTurn),
            BuildTransition(EQiuActionStatus.MyTurn, EQiuActionStatus.Standup),
            BuildTransition(EQiuActionStatus.Standup, EQiuActionStatus.None),
        ],
        reset: BuildTransition('*', EQiuActionStatus.None, (from, to) => console.log(from, to)),
    }
};

const fsm = new StateMachine(option);
fsm.transition().step();
fsm.transition().reset();

现在来给这个类添砖加瓦:

一. 需要能获取到当前的状态:

export class StateMachine {
    ...
    /** 获取当前状态 */
    state() {
        return this._curState;
    }
    ....
}

二. 状态转换是有前提的,所以需要一个方法,判断能不能进行转换:

export class StateMachine {
    ...
    /** 是否可以进行 `t` 转换 */
    can(t: keyof T) {
        ...
    }
    ....
}

三. 还需要状态转换前、后、错误回调:

export class StateMachine {
    ...
    onBefore?: (from: State, to: State) => void;
    onAfter?: (from: State, to: State) => void;
    onError?: (code: number, reason: string) => void;
    ....
}

上面代码的基本框架都已准备好,细节可以自行实现。想偷懒可以看这里:https://github.com/NathanLi/typescript-state-machine

完结

首先我们简单介绍了状态机,接下来使用 typescript 进行可用的简单实现。当然状态机远不止于此,比如有的开发者们,为了方便自己或产品同事们,还会自己写一套可视化、可配置的状态机工具。

你可能感兴趣的:(小技巧)